diff --git a/Gemfile b/Gemfile index 1d7e16841b6a3..b30b30664af27 100644 --- a/Gemfile +++ b/Gemfile @@ -53,5 +53,7 @@ group :test do gem 'allure-rspec' # Manipulate Time.now in specs gem 'timecop' + # stub and set expectations on HTTP requests + gem 'webmock', '~> 3.18' end diff --git a/Gemfile.lock b/Gemfile.lock index 2b114786be28f..2b40468f5f23e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -42,6 +42,7 @@ PATH jsobfu json lru_redux + mcp (= 0.13.0) metasm metasploit-concern metasploit-credential (>= 6.0.21) @@ -223,6 +224,9 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.4) cookiejar (0.3.4) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) csv (3.3.2) daemons (1.4.1) @@ -281,6 +285,7 @@ GEM gyoku (1.4.0) builder (>= 2.1.2) rexml (~> 3.0) + hashdiff (1.2.1) hashery (2.1.2) hrr_rb_ssh (0.4.2) hrr_rb_ssh-ed25519 (0.4.2) @@ -304,6 +309,9 @@ GEM jsobfu (0.4.2) rkelly-remix json (2.15.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) license_finder (5.11.1) bundler @@ -322,6 +330,8 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) lru_redux (1.1.0) + mcp (0.13.0) + json-schema (>= 4.1) memory_profiler (1.1.0) metasm (1.0.5) metasploit-concern (5.0.5) @@ -649,6 +659,10 @@ GEM useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) websocket-driver (0.7.7) base64 @@ -699,6 +713,7 @@ DEPENDENCIES simplecov (= 0.18.2) test-prof timecop + webmock (~> 3.18) yard BUNDLED WITH diff --git a/config/mcp_config.yaml.example b/config/mcp_config.yaml.example new file mode 100644 index 0000000000000..435777fcb6b90 --- /dev/null +++ b/config/mcp_config.yaml.example @@ -0,0 +1,33 @@ +# Metasploit RPC API connection (MessagePack) +msf_api: + type: messagepack + host: localhost + port: 55553 + ssl: true + endpoint: /api/ + user: msfuser + password: CHANGEME + auto_start_rpc: true # Automatically start the RPC server if not running (default: true) + +# MCP server configuration +mcp: + transport: stdio # stdio (default) or http + # MCP server network configuration (for HTTP transport only) + host: localhost # Host to bind to (default: localhost) + port: 3000 # Port to listen on (default: 3000) + +# Rate limiting (optional - defaults shown) +rate_limit: + enabled: true + requests_per_minute: 60 + # If the `burst_size` is greater than `requests_per_minute`, a user will be allowed to exceed the rate limit temporarily. + # For example, with `requests_per_minute=5` and `burst_size=10`, a user could make 10 requests in a short period, + # but then would be limited to 5 requests per minute thereafter. + burst_size: 10 + +# Logging (optional - defaults shown) +logging: + enabled: false + level: INFO # DEBUG, INFO, WARN, ERROR + log_file: ~/.msf4/logs/msfmcp.log + sanitize: true diff --git a/config/mcp_config_jsonrpc.yaml.example b/config/mcp_config_jsonrpc.yaml.example new file mode 100644 index 0000000000000..12ca832304b56 --- /dev/null +++ b/config/mcp_config_jsonrpc.yaml.example @@ -0,0 +1,32 @@ +# Metasploit RPC API connection (JSON-RPC) +msf_api: + type: json-rpc + host: localhost + port: 8081 + ssl: true + endpoint: /api/v1/json-rpc + token: YOUR_BEARER_TOKEN_HERE + # auto_start_rpc is not supported for JSON-RPC (only MessagePack) + +# MCP server configuration +mcp: + transport: stdio # stdio (default) or http + # MCP server network configuration (for HTTP transport only) + host: localhost # Host to bind to (default: localhost) + port: 3000 # Port to listen on (default: 3000) + +# Rate limiting (optional - defaults shown) +rate_limit: + enabled: true + requests_per_minute: 60 + # If the `burst_size` is greater than `requests_per_minute`, a user will be allowed to exceed the rate limit temporarily. + # For example, with `requests_per_minute=5` and `burst_size=10`, a user could make 10 requests in a short period, + # but then would be limited to 5 requests per minute thereafter. + burst_size: 10 + +# Logging (optional - defaults shown) +logging: + enabled: false + level: INFO # DEBUG, INFO, WARN, ERROR + log_file: ~/.msf4/logs/msfmcp.log + sanitize: 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 new file mode 100644 index 0000000000000..c46d7b671014c --- /dev/null +++ b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md @@ -0,0 +1,366 @@ +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. + +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. + +## Architecture + +```mermaid +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"] + 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"] + + mcp_layer --> rpc_manager + rpc_manager --> api_client + end + + msf["Metasploit Framework
(msfrpcd)"] + + ai_app -- "MCP Protocol (stdio or HTTP)
JSON-RPC 2.0" --> mcp_layer + api_client -- "HTTP/HTTPS" --> msf +``` + +## Quick Start + +The simplest way to start the MCP server is with no arguments: + +``` +./msfmcpd +``` + +The server automatically detects whether a Metasploit RPC server is already running on the configured port. If not, it starts one automatically with randomly generated credentials. + +To use specific credentials: + +``` +./msfmcpd --user your_username --password your_password +``` + +## Configuration + +### Configuration File + +Copy the example configuration and edit it: + +``` +cp config/mcp_config.yaml.example config/mcp_config.yaml +``` + +A MessagePack RPC configuration looks like this: + +```yaml +msf_api: + type: messagepack + host: localhost + port: 55553 + ssl: true + endpoint: /api/ + user: msfuser + password: CHANGEME + auto_start_rpc: true + +mcp: + transport: stdio + +rate_limit: + enabled: true + requests_per_minute: 60 + burst_size: 10 + +logging: + enabled: false + level: INFO + log_file: msfmcp.log +``` + +For JSON-RPC with bearer token authentication, use the JSON-RPC example instead: + +``` +cp config/mcp_config_jsonrpc.yaml.example config/mcp_config.yaml +``` + +### Command-Line Options + +``` +./msfmcpd --help + +Options: + --config PATH Path to configuration file + --enable-logging Enable file logging with sanitization + --log-file PATH Log file path (overrides config file) + --user USER MSF API username (for MessagePack auth) + --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') + -h, --help Show this help message + -v, --version Show version information +``` + +### Environment Variable Overrides + +All configuration settings can be overridden by environment variables: + +| Variable | Description | +|---|---| +| `MSF_API_TYPE` | Connection type (`messagepack` or `json-rpc`) | +| `MSF_API_HOST` | Metasploit RPC API host | +| `MSF_API_PORT` | Metasploit RPC API port | +| `MSF_API_SSL` | Use SSL for Metasploit RPC API (`true` or `false`) | +| `MSF_API_ENDPOINT` | Metasploit RPC API endpoint | +| `MSF_API_USER` | RPC API username (for MessagePack auth) | +| `MSF_API_PASSWORD` | RPC API password (for MessagePack auth) | +| `MSF_API_TOKEN` | RPC API token (for JSON-RPC auth) | +| `MSF_AUTO_START_RPC` | Auto-start RPC server (`true` or `false`) | +| `MSF_MCP_TRANSPORT` | MCP transport type (`stdio` or `http`) | +| `MSF_MCP_HOST` | MCP server host (for HTTP transport) | +| `MSF_MCP_PORT` | MCP server port (for HTTP transport) | + +Example using environment variables: + +``` +MSF_API_HOST=192.168.33.44 ./msfmcpd --config ./config/mcp_config.yaml +``` + +## Automatic RPC Server Management + +When using MessagePack RPC on localhost, the MCP server can automatically manage the Metasploit RPC server lifecycle. This is enabled by default. + +### How It Works + +1. **Detection**: On startup, the MCP server probes the configured RPC port to check if a server is already running. +2. **Auto-start**: If no server is detected, it spawns the `msfrpcd` executable as a child process. +3. **Credentials**: If no username and password are provided, random credentials are generated automatically and used for both the RPC server and client authentication. +4. **Wait**: After starting, it polls the port until the RPC server becomes available (timeout: 30 seconds). +5. **Shutdown**: When the MCP server shuts down (via Ctrl+C or SIGTERM), it cleans up the managed RPC process. + +**Note**: If an RPC server is already running, credentials must be provided via `--user`/`--password`, config file, or environment variables to authenticate with it. + +### Database Support + +The auto-started RPC server creates a framework instance with database support enabled by default. If the database is not running when the RPC server starts, a warning is displayed: + +``` +[WARNING] Database is not available. Some MCP tools that rely on the database will not work. +[WARNING] Start the database and restart the MCP server to enable full functionality. +``` + +Tools that query the database (`msf_host_info`, `msf_service_info`, `msf_vulnerability_info`, `msf_note_info`, `msf_credential_info`, `msf_loot_info`) require a running database. To initialize and start the database: + +``` +msfdb init +msfdb start +``` + +Then restart the MCP server. + +### Disabling Auto-Start + +Auto-start can be disabled in three ways: + +- CLI flag: `--no-auto-start-rpc` +- Config file: `auto_start_rpc: false` in the `msf_api` section +- Environment variable: `MSF_AUTO_START_RPC=false` + +Auto-start is also not available when: + +- The API type is `json-rpc` (requires SSL certificates and a web server) +- The host is a remote address (cannot start a server on a remote machine) + +When auto-start is disabled and no RPC server is running, you must start `msfrpcd` manually: + +``` +msfrpcd -U your_username -P your_password -p 55553 +``` + +## MCP Tools + +The server exposes 8 tools to AI applications via the MCP protocol. + +### msf_search_modules + +Search for Metasploit modules by keywords, CVE IDs, or module names. + +- `query` (string, required): Search terms (e.g., `windows smb`, `CVE-2017-0144`) +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_module_info + +Get detailed information about a specific Metasploit module. + +- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `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 + +Query discovered hosts from the Metasploit database. + +- `workspace` (string, optional): Workspace name (default: `default`) +- `addresses` (string, optional): Filter by IP/CIDR (e.g., `192.168.1.0/24`) +- `only_up` (boolean, optional): Only return alive hosts (default: false) +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_service_info + +Query discovered services on hosts. + +- `workspace` (string, optional): Workspace name +- `names` (string, optional): Filter by service names, comma-separated (e.g., `http`, `ldap,ssh`) +- `host` (string, optional): Filter by host IP +- `ports` (string, optional): Filter by port or range (e.g., `80,443` or `1-1024`) +- `protocol` (string, optional): Protocol filter (`tcp` or `udp`) +- `only_up` (boolean, optional): Only return running services (default: false) +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_vulnerability_info + +Query discovered vulnerabilities. + +- `workspace` (string, optional): Workspace name +- `names` (array of strings, optional): Filter by vulnerability names (exact, case-sensitive module names) +- `host` (string, optional): Filter by host IP +- `ports` (string, optional): Filter by port or range +- `protocol` (string, optional): Protocol filter (`tcp` or `udp`) +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_note_info + +Query notes stored in the database. + +- `workspace` (string, optional): Workspace name +- `type` (string, optional): Filter by note type (e.g., `ssl.certificate`, `smb.fingerprint`) +- `host` (string, optional): Filter by host IP +- `ports` (string, optional): Filter by port or range +- `protocol` (string, optional): Protocol filter (`tcp` or `udp`) +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_credential_info + +Query discovered credentials. + +- `workspace` (string, optional): Workspace name +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +### msf_loot_info + +Query collected loot (files, data dumps). + +- `workspace` (string, optional): Workspace name +- `limit` (integer, optional): Max results (1-1000, default: 100) +- `offset` (integer, optional): Pagination offset (default: 0) + +## Integration with AI Applications + +Add the MCP server to your AI application configuration. The exact format depends on the client. + +### Claude Desktop / Cursor + +```json +{ + "mcpServers": { + "metasploit": { + "command": "/path/to/metasploit-framework/msfmcpd", + "args": [ + "--config", + "/path/to/config/mcp_config.yaml" + ], + "env": {} + } + } +} +``` + +### Using RVM + +If you use RVM to manage Ruby versions, specify the full path to RVM so the correct Ruby and gemset are used: + +```json +{ + "mcpServers": { + "metasploit": { + "command": "/your/home_dir/.rvm/bin/rvm", + "args": [ + "in", + "/path/to/metasploit-framework", + "do", + "./msfmcpd", + "--config", + "config/mcp_config.yaml" + ] + } + } +} +``` + +## Security Considerations + +### 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. + +### Credential Management + +Configuration files should use `chmod 600` permissions. Credentials are transmitted securely to the Metasploit Framework API and are never cached or logged by the MCP server. + +### Rate Limiting + +The server applies rate limiting to all MCP tools using a token bucket algorithm. Default: 60 requests per minute with a burst of 10 requests. This is configurable in the `rate_limit` section of the configuration file. + +### Logging + +Logging is disabled by default. When enabled (via `--enable-logging` or config), sensitive data (passwords, tokens, API keys) is automatically redacted. Log files should be protected with `chmod 600`. + +### Error Handling + +Stack traces are never exposed to clients. Error messages are sanitized to avoid leaking credentials. Metasploit API errors are wrapped in the MCP error format. + +## Testing with MCP Inspector + +The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an interactive developer tool for testing and debugging MCP servers. It runs directly through `npx`: + +``` +npx @modelcontextprotocol/inspector +``` + +## Troubleshooting + +### Connection Refused or Timeout + +1. Verify the RPC daemon is running: `ps aux | grep msfrpcd` +2. Check the port is listening: `netstat -an | grep 55553` +3. Test connectivity: `curl -k -v https://localhost:55553/api/` + +### Authentication Failures + +For MessagePack RPC, verify the username and password in your configuration file or CLI arguments. For JSON-RPC, verify the bearer token is valid and has not expired. + +### Database Not Available + +If database-dependent tools return errors, ensure the database is running: + +``` +msfdb init +msfdb start +``` + +Then restart the MCP server. + +### Rate Limit Exceeded + +Increase the rate limit in your configuration file: + +```yaml +rate_limit: + requests_per_minute: 120 + burst_size: 20 +``` diff --git a/docs/navigation.rb b/docs/navigation.rb index 5364e3f527fff..d9e049ab983f6 100644 --- a/docs/navigation.rb +++ b/docs/navigation.rb @@ -448,6 +448,9 @@ def without_prefix(prefix) { path: 'How-to-use-Metasploit-with-ngrok.md' }, + { + path: 'How-to-use-Metasploit-MCP-Server.md' + }, ] }, ] diff --git a/lib/msf/core/mcp.rb b/lib/msf/core/mcp.rb new file mode 100644 index 0000000000000..b7cca12f594f4 --- /dev/null +++ b/lib/msf/core/mcp.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Main entry point for MSF MCP Server +module Msf + module MCP + VERSION = '0.1.0' + end +end + +# Load the base configuration (for default paths, etc.) +require 'msf/base/config' + +# Load the base Rex libraries +require 'rex/socket' +require 'rex/logging' +require 'rex/logging/log_sink' + +module Msf + module MCP + # Log source identifier for all MCP log messages. + LOG_SOURCE = 'mcp' + + # Log level aliases — semantic names for Rex::Logging level constants. + LOG_DEBUG = Rex::Logging::LEV_3 + LOG_INFO = Rex::Logging::LEV_2 + LOG_WARN = Rex::Logging::LEV_1 + LOG_ERROR = Rex::Logging::LEV_0 + end +end + +# Load the MCP-specific logging components +require_relative 'mcp/logging/sinks/json_stream' +require_relative 'mcp/logging/sinks/json_flatfile' +require_relative 'mcp/logging/sinks/sanitizing' +require_relative 'mcp/middleware/request_logger' + +# Error classes +require_relative 'mcp/errors' + +# Configuration Layer +require_relative 'mcp/config/loader' +require_relative 'mcp/config/validator' + +# Security Layer +require_relative 'mcp/security/input_validator' +require_relative 'mcp/security/rate_limiter' + +# Metasploit Client Layer +require_relative 'mcp/rpc_manager' +require_relative 'mcp/metasploit/messagepack_client' +require_relative 'mcp/metasploit/jsonrpc_client' +require_relative 'mcp/metasploit/client' +require_relative 'mcp/metasploit/response_transformer' + +# MCP SDK +require 'mcp' + +# MCP Layer +require_relative 'mcp/tools/tool_helper' +require_relative 'mcp/tools/search_modules' +require_relative 'mcp/tools/module_info' +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/server' + +# Application Layer +require_relative 'mcp/application' + +# Make logging stubs (ilog, elog, dlog, wlog) +include Rex::Logging + diff --git a/lib/msf/core/mcp/application.rb b/lib/msf/core/mcp/application.rb new file mode 100644 index 0000000000000..8d1ba4efe5a31 --- /dev/null +++ b/lib/msf/core/mcp/application.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'optparse' + +module Msf::MCP + # Main application class that orchestrates the MCP server startup and lifecycle + class Application + VERSION = '0.1.0' + BANNER = <<~BANNER + MSF MCP Server v#{VERSION} + Model Context Protocol server for Metasploit Framework + BANNER + + # For testing purposes: + attr_reader :config, :msf_client, :mcp_server, :rate_limiter, :options, :rpc_manager + + # Initialize the application with command-line arguments + # + # @param argv [Array] Command-line arguments + # @param output [IO] Output stream for messages (default: $stderr) + def initialize(argv = ARGV, output: $stderr) + @argv = argv.dup + @output = output + @options = {} + @config = nil + @msf_client = nil + @mcp_server = nil + @rate_limiter = nil + @rpc_manager = nil + end + + # Run the application + # + # @return [void] + def run + parse_arguments + install_signal_handlers + load_configuration + validate_configuration + initialize_logger + initialize_rate_limiter + ensure_rpc_server + initialize_metasploit_client + authenticate_metasploit + initialize_mcp_server + start_mcp_server + rescue Msf::MCP::Config::ValidationError, Msf::MCP::Config::ConfigurationError => e + handle_configuration_error(e) + rescue Msf::MCP::Metasploit::ConnectionError => e + handle_connection_error(e) + rescue Msf::MCP::Metasploit::APIError => e + handle_api_error(e) + rescue Msf::MCP::Metasploit::AuthenticationError => e + handle_authentication_error(e) + rescue Msf::MCP::Metasploit::RpcStartupError => e + handle_rpc_startup_error(e) + rescue StandardError => e + handle_fatal_error(e) + end + + # Shutdown the application gracefully + # + # Performs cleanup operations before process termination: + # - Logs shutdown event via Rex + # - Closes MCP server and Metasploit client connections + # - Cleans up resources + # + # @param signal [String] Signal name (e.g., 'INT', 'TERM') + # @return [void] + def shutdown(signal = 'INT') + ilog({ + message: 'Shutting down', + context: { signal: "SIG#{signal}" } + }, LOG_SOURCE, LOG_INFO) + @mcp_server&.shutdown + @rpc_manager&.stop_rpc_server + @output.puts "\nShutdown complete" + end + + private + + # Parse command-line arguments + # + # @return [void] + def parse_arguments + parser = OptionParser.new do |opts| + opts.banner = BANNER + "\nUsage: msfmcp [options]" + + opts.on('--config PATH', 'Path to configuration file') do |path| + @options[:config_path] = File.expand_path(path) + end + + opts.on('--enable-logging', 'Enable file logging') do + @options[:enable_logging_cli] = true + end + + opts.on('--log-file PATH', 'Log file path (overrides config file)') do |path| + @options[:log_file_cli] = path + end + + opts.on('--user USER', 'MSF API username (for MessagePack auth)') do |user| + @options[:msf_user_cli] = user + end + + opts.on('--password PASS', 'MSF API password (for MessagePack auth)') do |password| + @options[:msf_password_cli] = password + end + + opts.on('--no-auto-start-rpc', 'Disable automatic RPC server startup') do + @options[:no_auto_start_rpc] = true + end + + opts.on('--mcp-transport TRANSPORT', 'MCP server transport type (\'stdio\' or \'http\')') do |transport| + @options[:mcp_transport] = transport + end + + opts.on('-h', '--help', 'Show this help message') do + @output.puts opts + exit 0 + end + + opts.on('-v', '--version', 'Show version information') do + @output.puts "msfmcp version #{VERSION}" + exit 0 + end + end + + parser.parse!(@argv) + end + + # Register a Rex log source when logging is enabled. + # + # Selects a JsonFlatfile sink pointed at the configured log path and wraps it + # with the sanitizing middleware unless sanitization has been explicitly + # disabled in the config. + # + # Priority: CLI flags > config file > defaults + # + # @return [void] + def initialize_logger + return unless @options[:enable_logging_cli] || @config.dig(:logging, :enabled) + + log_file = @options[:log_file_cli] || @config.dig(:logging, :log_file) + level = @config.dig(:logging, :level) + threshold = case @config.dig(:logging, :level).upcase + when 'DEBUG' + Rex::Logging::LEV_3 + when 'INFO' + Rex::Logging::LEV_2 + when 'WARN' + Rex::Logging::LEV_1 + when 'ERROR' + Rex::Logging::LEV_0 + end + inner = Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file) + sink = @config.dig(:logging, :sanitize) ? Msf::MCP::Logging::Sinks::Sanitizing.new(inner) : inner + + deregister_log_source(LOG_SOURCE) if log_source_registered?(LOG_SOURCE) + register_log_source(LOG_SOURCE, sink, threshold) + end + + # Install signal handlers for graceful shutdown + # + # @return [void] + def install_signal_handlers + Signal.trap('INT') { shutdown('INT'); exit 0 } + Signal.trap('TERM') { shutdown('TERM'); exit 0 } + end + + # Load configuration from file or use defaults + # + # @return [void] + def load_configuration + if @options[:config_path] + @output.puts "Loading configuration from #{@options[:config_path]}" + @config = Msf::MCP::Config::Loader.load(@options[:config_path]) + else + @output.puts "No configuration file specified, using defaults" + @config = Msf::MCP::Config::Loader.load_from_hash({}) + end + + # Apply CLI authentication overrides (highest priority) + if @options[:msf_user_cli] + @config[:msf_api][:user] = @options[:msf_user_cli] + end + if @options[:msf_password_cli] + @config[:msf_api][:password] = @options[:msf_password_cli] + end + if @options[:no_auto_start_rpc] + @config[:msf_api][:auto_start_rpc] = false + end + if @options[:mcp_transport] + @config[:mcp][:transport] = @options[:mcp_transport] + end + end + + # Validate the loaded configuration + # + # @return [void] + def validate_configuration + @output.puts "Validating configuration..." + Msf::MCP::Config::Validator.validate!(@config) + @output.puts "Configuration valid" + end + + # Initialize the rate limiter + # + # @return [void] + def initialize_rate_limiter + @rate_limiter = Msf::MCP::Security::RateLimiter.new( + requests_per_minute: @config.dig(:rate_limit, :requests_per_minute) || 60, + burst_size: @config.dig(:rate_limit, :burst_size) + ) + end + + # Ensure the Metasploit RPC server is available, auto-starting if needed + # + # @return [void] + def ensure_rpc_server + @rpc_manager = Msf::MCP::RpcManager.new( + config: @config, + output: @output + ) + @rpc_manager.ensure_rpc_available + end + + # Initialize the Metasploit client + # + # @return [void] + def initialize_metasploit_client + @output.puts "Connecting to Metasploit RPC at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]}" + @msf_client = Msf::MCP::Metasploit::Client.new( + api_type: @config[:msf_api][:type], + host: @config[:msf_api][:host], + port: @config[:msf_api][:port], + endpoint: @config[:msf_api][:endpoint], + token: @config[:msf_api][:token], + ssl: @config[:msf_api][:ssl] + ) + end + + # Authenticate with Metasploit if using MessagePack + # + # @return [void] + def authenticate_metasploit + if @config[:msf_api][:type] == 'messagepack' + @output.puts "Authenticating with Metasploit..." + @msf_client.authenticate(@config[:msf_api][:user].to_s, @config[:msf_api][:password].to_s) + @output.puts "Authentication successful" + else + @output.puts "Using JSON-RPC with token authentication" + end + end + + # Initialize the MCP server + # + # @return [void] + def initialize_mcp_server + @output.puts "Initializing MCP server..." + @mcp_server = Msf::MCP::Server.new( + msf_client: @msf_client, + rate_limiter: @rate_limiter + ) + end + + # Start the MCP server with configured transport + # + # @return [void] + def start_mcp_server + transport = (@config.dig(:mcp, :transport) || 'stdio').to_sym + host = @config.dig(:mcp, :host) || 'localhost' + port = @config.dig(:mcp, :port) || 3000 + + if transport == :http + @output.puts "Starting MCP server on HTTP transport..." + @output.puts "Server listening on http://#{host}:#{port}" + @output.puts "Press Ctrl+C to shutdown" + @mcp_server.start(transport: :http, host: host, port: port) + else + @output.puts "Starting MCP server on stdio transport..." + @output.puts "Server ready - waiting for MCP requests" + @output.puts "Press Ctrl+C to shutdown" + @mcp_server.start(transport: :stdio) + end + end + + # Error handlers + + def handle_configuration_error(error) + @output.puts "Configuration validation failed: #{error.message}" + exit 1 + end + + def handle_connection_error(error) + elog({ + message: 'Connection error', + context: { host: @config[:msf_api][:host], port: @config[:msf_api][:port] }, + exception: error + }, LOG_SOURCE, LOG_ERROR) + @output.puts "Connection error to Metasploit RPC at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]} - #{error.message}" + exit 1 + end + + def handle_api_error(error) + elog({ message: 'Metasploit API error', exception: error }, LOG_SOURCE, LOG_ERROR) + @output.puts "Metasploit API error: #{error.message}" + exit 1 + end + + def handle_authentication_error(error) + elog({ + message: 'Authentication error', + context: { username: @config[:msf_api][:user].to_s }, + exception: error + }, LOG_SOURCE, LOG_ERROR) + @output.puts "Authentication error (username: #{@config[:msf_api][:user]}): #{error.message}" + exit 1 + end + + def handle_rpc_startup_error(error) + elog({ message: 'RPC startup error', exception: error }, LOG_SOURCE, LOG_ERROR) + @output.puts "RPC startup error: #{error.message}" + exit 1 + end + + def handle_fatal_error(error) + elog({ message: 'Fatal error during startup', exception: error }, LOG_SOURCE, LOG_ERROR) + @output.puts "Fatal error: #{error.message}" + @output.puts error.backtrace.first(5).join("\n") if error.backtrace + exit 1 + end + end +end diff --git a/lib/msf/core/mcp/config/loader.rb b/lib/msf/core/mcp/config/loader.rb new file mode 100644 index 0000000000000..75f9cf39e3b7e --- /dev/null +++ b/lib/msf/core/mcp/config/loader.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'yaml' + +module Msf::MCP + module Config + class Loader + # Load configuration from YAML file with environment variable overrides + # + # @param file_path [String] Path to YAML configuration file + # @return [Hash] Configuration hash with symbolized keys + # @raise [ConfigurationError] If file not found or invalid YAML + def self.load(file_path) + unless File.exist?(file_path) + raise ConfigurationError, "Configuration file not found: #{file_path}" + end + + begin + config = YAML.safe_load_file(file_path, symbolize_names: true) + rescue Psych::SyntaxError => e + raise ConfigurationError, "Invalid YAML syntax in #{file_path}: #{e.message}" + end + + unless config.is_a?(Hash) + raise ConfigurationError, "Configuration file must contain a YAML hash/dictionary" + end + + apply_defaults(config) + apply_env_overrides(config) + config + end + + # Load configuration from hash (for testing) + # + # @param config_hash [Hash] Configuration hash + # @return [Hash] Configuration hash with defaults and env overrides + def self.load_from_hash(config_hash) + config = config_hash.dup + apply_defaults(config) + apply_env_overrides(config) + config + end + + + private + + # Apply default values to configuration + # + # @param config [Hash] Configuration hash to modify in place + def self.apply_defaults(config) + config[:msf_api] ||= {} + config[:mcp] ||= {} + config[:rate_limit] ||= {} + config[:logging] ||= {} + + config[:msf_api][:type] ||= 'messagepack' + config[:msf_api][:host] ||= 'localhost' + config[:msf_api][:port] ||= (config[:msf_api][:type] == 'json-rpc') ? 8081 : 55553 + + config[:msf_api][:ssl] = config[:msf_api].fetch(:ssl, true) + config[:msf_api][:auto_start_rpc] = config[:msf_api].fetch(:auto_start_rpc, true) + + config[:msf_api][:endpoint] ||= case config[:msf_api][:type] + when 'json-rpc' + Msf::MCP::Metasploit::JsonRpcClient::DEFAULT_ENDPOINT + else + Msf::MCP::Metasploit::MessagePackClient::DEFAULT_ENDPOINT + end + + config[:mcp][:transport] ||= 'stdio' + + if config[:mcp][:transport] == 'http' + config[:mcp][:host] ||= 'localhost' + config[:mcp][:port] ||= 3000 + end + + config[:rate_limit][:enabled] = config[:rate_limit].fetch(:enabled, true) + config[:rate_limit][:requests_per_minute] ||= 60 + config[:rate_limit][:burst_size] ||= 10 + + config[:logging][:enabled] = config[:logging].fetch(:enabled, false) + config[:logging][:level] ||= 'INFO' + config[:logging][:log_file] ||= File.join(Msf::Config.log_directory, 'msfmcp.log') + config[:logging][:sanitize] = config[:logging].fetch(:sanitize, true) + end + + # Apply environment variable overrides + # + # @param config [Hash] Configuration hash to modify in place + def self.apply_env_overrides(config) + # Ensure nested hashes exist + config[:msf_api] ||= {} + config[:mcp] ||= {} + + # MSF API overrides + config[:msf_api][:type] = ENV['MSF_API_TYPE'] if ENV['MSF_API_TYPE'] + config[:msf_api][:host] = ENV['MSF_API_HOST'] if ENV['MSF_API_HOST'] + config[:msf_api][:port] = ENV['MSF_API_PORT'].to_i if ENV['MSF_API_PORT'] + config[:msf_api][:ssl] = parse_boolean(ENV['MSF_API_SSL']) if ENV['MSF_API_SSL'] && !ENV['MSF_API_SSL'].empty? + config[:msf_api][:endpoint] = ENV['MSF_API_ENDPOINT'] if ENV['MSF_API_ENDPOINT'] + config[:msf_api][:user] = ENV['MSF_API_USER'] if ENV['MSF_API_USER'] + config[:msf_api][:password] = ENV['MSF_API_PASSWORD'] if ENV['MSF_API_PASSWORD'] + config[:msf_api][:token] = ENV['MSF_API_TOKEN'] if ENV['MSF_API_TOKEN'] + config[:msf_api][:auto_start_rpc] = parse_boolean(ENV['MSF_AUTO_START_RPC']) if ENV['MSF_AUTO_START_RPC'] + + # MCP transport override + config[:mcp][:transport] = ENV['MSF_MCP_TRANSPORT'] if ENV['MSF_MCP_TRANSPORT'] + + # MCP server network overrides + config[:mcp][:host] = ENV['MSF_MCP_HOST'] if ENV['MSF_MCP_HOST'] + config[:mcp][:port] = ENV['MSF_MCP_PORT'].to_i if ENV['MSF_MCP_PORT'] + end + + # Parse a string value into a boolean + # + # @param value [String] String to parse ('true', '1', 'yes' → true; anything else → false) + # @return [Boolean] + def self.parse_boolean(value) + %w[true 1 yes].include?(value.to_s.downcase) + end + end + end +end diff --git a/lib/msf/core/mcp/config/validator.rb b/lib/msf/core/mcp/config/validator.rb new file mode 100644 index 0000000000000..176c71ce4ef87 --- /dev/null +++ b/lib/msf/core/mcp/config/validator.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +module Msf::MCP + module Config + class Validator + VALID_API_TYPES = %w[messagepack json-rpc].freeze + VALID_TRANSPORTS = %w[stdio http].freeze + + # Validate configuration hash (class method) + # + # @param config [Hash] Configuration hash to validate + # @return [true] If validation passes + # @raise [ValidationError] If validation fails + def self.validate!(config) + new.validate!(config) + end + + # Validate configuration hash (instance method) + # + # @param config [Hash] Configuration hash to validate + # @return [true] If validation passes + # @raise [ValidationError] If validation fails + def validate!(config) + errors = {} + + # Check msf_api section exists + unless config[:msf_api].is_a?(Hash) + errors[:msf_api] = "configuration section is required" + raise ValidationError.new(errors) + end + + # Validate API type + if config[:msf_api][:type] && !VALID_API_TYPES.include?(config[:msf_api][:type]) + errors[:'msf_api.type'] = "must be one of the valid API types: #{VALID_API_TYPES.join(', ')}" + end + + # Validate API type + if config[:msf_api][:host] && config[:msf_api][:host].to_s.strip.empty? + errors[:'msf_api.host'] = "must be a non-empty string" + end + + # Validate mcp section type + if config.key?(:mcp) && !config[:mcp].is_a?(Hash) + errors[:mcp] = "must be a configuration hash" + end + + # Validate transport + if config[:mcp].is_a?(Hash) && config[:mcp][:transport] && !VALID_TRANSPORTS.include?(config[:mcp][:transport]) + errors[:'mcp.transport'] = "must be one of the valid transport: #{VALID_TRANSPORTS.join(', ')}" + end + + # Validate port + if config[:msf_api][:port] + port = config[:msf_api][:port].to_i + unless port.between?(1, 65535) + errors[:'msf_api.port'] = "must be between 1 and 65535" + end + end + + # Validate SSL option + if config[:msf_api].key?(:ssl) && ![true, false].include?(config[:msf_api][:ssl]) + errors[:'msf_api.ssl'] = "must be boolean (true or false)" + end + + # Validate auto_start_rpc option + if config[:msf_api].key?(:auto_start_rpc) && ![true, false].include?(config[:msf_api][:auto_start_rpc]) + errors[:'msf_api.auto_start_rpc'] = "must be boolean (true or false)" + end + + # Validate MCP port + if config[:mcp].is_a?(Hash) && config[:mcp][:port] + port = config[:mcp][:port].to_i + unless port.between?(1, 65535) + errors[:'mcp.port'] = "must be between 1 and 65535" + end + end + + # Validate conditional requirements based on API type + if config[:msf_api][:type] == 'messagepack' + validate_messagepack_auth(config, errors) + elsif config[:msf_api][:type] == 'json-rpc' + validate_jsonrpc_auth(config, errors) + end + + # Validate rate_limit section + if config.key?(:rate_limit) + if config[:rate_limit].is_a?(Hash) + validate_rate_limit(config, errors) + else + errors[:rate_limit] = "must be a configuration hash" + end + end + + # Validate logging section + if config.key?(:logging) + if config[:logging].is_a?(Hash) + validate_logging(config, errors) + else + errors[:logging] = "must be a configuration hash" + end + end + + # Raise error if any validation failed + unless errors.empty? + raise ValidationError.new(errors) + end + + true + end + + private + + LOCALHOST_HOSTS = %w[localhost 127.0.0.1 ::1].freeze + + # Validate MessagePack authentication fields + # + # Credentials are optional when auto-start can generate random ones + # (auto_start_rpc enabled + localhost). If neither user nor password is + # provided under those conditions, validation passes and the RPC manager + # will generate random credentials at startup. + def validate_messagepack_auth(config, errors) + user_provided = config[:msf_api][:user] && !config[:msf_api][:user].to_s.strip.empty? + password_provided = config[:msf_api][:password] && !config[:msf_api][:password].to_s.strip.empty? + + # Both provided — nothing to validate + return if user_provided && password_provided + + # Neither provided and auto-start can generate them — OK + return if !user_provided && !password_provided && credentials_can_be_generated?(config) + + # Otherwise, require both + unless user_provided + errors[:'msf_api.user'] = "is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable" + end + + unless password_provided + errors[:'msf_api.password'] = "is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable" + end + end + + # Whether the RPC manager can generate random credentials for this config. + # + # @param config [Hash] Configuration hash + # @return [Boolean] + def credentials_can_be_generated?(config) + config[:msf_api][:auto_start_rpc] != false && + LOCALHOST_HOSTS.include?(config[:msf_api][:host].to_s.downcase) + end + + # Validate JSON-RPC authentication fields + def validate_jsonrpc_auth(config, errors) + unless config[:msf_api][:token] && !config[:msf_api][:token].to_s.strip.empty? + errors[:'msf_api.token'] = "is required for JSON-RPC authentication" + end + end + + # Validate rate_limit section fields + def validate_rate_limit(config, errors) + rate_limit = config[:rate_limit] + + if rate_limit.key?(:enabled) && ![true, false].include?(rate_limit[:enabled]) + errors[:'rate_limit.enabled'] = "must be boolean (true or false)" + end + + if rate_limit.key?(:requests_per_minute) + unless rate_limit[:requests_per_minute].is_a?(Integer) && rate_limit[:requests_per_minute] >= 1 + errors[:'rate_limit.requests_per_minute'] = "must be an integer >= 1" + end + end + + if rate_limit.key?(:burst_size) + unless rate_limit[:burst_size].is_a?(Integer) && rate_limit[:burst_size] >= 1 + errors[:'rate_limit.burst_size'] = "must be an integer >= 1" + end + end + end + + VALID_LOG_LEVELS = %w[DEBUG INFO WARN ERROR].freeze + + # Validate logging section fields + def validate_logging(config, errors) + logging = config[:logging] + + if logging.key?(:enabled) && ![true, false].include?(logging[:enabled]) + errors[:'logging.enabled'] = "must be boolean (true or false)" + end + + if logging.key?(:level) && !VALID_LOG_LEVELS.include?(logging[:level].to_s.upcase) + errors[:'logging.level'] = "must be one of: #{VALID_LOG_LEVELS.join(', ')}" + end + + if logging.key?(:log_file) && logging[:log_file].to_s.strip.empty? + errors[:'logging.log_file'] = "must be a non-empty string" + end + + if logging.key?(:sanitize) && ![true, false].include?(logging[:sanitize]) + errors[:'logging.sanitize'] = "must be boolean (true or false)" + end + end + end + end +end diff --git a/lib/msf/core/mcp/errors.rb b/lib/msf/core/mcp/errors.rb new file mode 100644 index 0000000000000..0ba240c18a6d4 --- /dev/null +++ b/lib/msf/core/mcp/errors.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Msf::MCP + ## + # Base error class for all Msf::MCP errors + # + class Error < StandardError; end + + ## + # Configuration Layer Errors + # + module Config + + class ConfigurationError < Error; end + + class ValidationError < Error + attr_reader :errors + + def initialize(errors = {}) + @errors = errors + super(build_message) + end + + private + + def build_message + return "Configuration validation failed" if @errors.empty? + + messages = @errors.map { |field, error| "#{field} #{error}" } + "Configuration validation failed:\n - #{messages.join("\n - ")}" + end + end + + end + + ## + # Security Layer Errors + # + module Security + + class ValidationError < Error; end + + class RateLimitExceededError < Error + attr_reader :retry_after + + def initialize(retry_after) + @retry_after = retry_after + super("Rate limit exceeded. Retry after #{retry_after} seconds.") + end + end + + end + + ## + # Metasploit Client Layer Errors + # + module Metasploit + + class AuthenticationError < Error; end + + class ConnectionError < Error; end + + class APIError < Error; end + + class RpcStartupError < Error; end + + end + +end diff --git a/lib/msf/core/mcp/logging/sinks/json_flatfile.rb b/lib/msf/core/mcp/logging/sinks/json_flatfile.rb new file mode 100644 index 0000000000000..9581d6f69e43d --- /dev/null +++ b/lib/msf/core/mcp/logging/sinks/json_flatfile.rb @@ -0,0 +1,24 @@ +# -*- coding: binary -*- +module Msf::MCP + module Logging + module Sinks + ### + # + # This class implements the LogSink interface and backs it against a + # JSON file on disk. + # + ### + class JsonFlatfile < Msf::MCP::Logging::Sinks::JsonStream + + # + # Creates a JSON flatfile log sink instance that will be configured to log to + # the supplied file path. + # + def initialize(file) + super(File.new(file, 'a')) + end + + end + end + end +end diff --git a/lib/msf/core/mcp/logging/sinks/json_stream.rb b/lib/msf/core/mcp/logging/sinks/json_stream.rb new file mode 100644 index 0000000000000..b884cd287a549 --- /dev/null +++ b/lib/msf/core/mcp/logging/sinks/json_stream.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Msf::MCP + module Logging + module Sinks + # A Rex LogSink that formats log messages as JSON and writes them to + # an IO stream (e.g. $stdout, a File, a StringIO). + # + # @example Writing JSON logs to $stderr + # sink = Msf::MCP::Logging::Sinks::JsonStream.new($stderr) + # register_log_source('mcp', sink, Rex::Logging::LEV_0) + # + # @example Backed by a file via JsonFlatfile + # sink = Msf::MCP::Logging::Sinks::JsonFlatfile.new('msfmcp.log') + # register_log_source('mcp', sink, Rex::Logging::LEV_0) + class JsonStream + include Rex::Logging::LogSink + + def initialize(stream) + @stream = stream + end + + def log(sev, src, level, msg) + log_entry = { + timestamp: get_current_timestamp, + severity: sev.to_s.upcase, + level: level.to_s, + source: src.to_s, + message: msg.to_s + } + + if msg.is_a?(Hash) + log_entry[:message] = msg[:message] if msg[:message] && !msg[:message].empty? + if msg[:context] && !msg[:context].empty? + log_entry[:context] = if debug_log_level? + msg[:context] + else + summarize_context(msg[:context]) + end + end + if msg[:exception] + log_entry[:exception] = if msg[:exception].is_a?(Exception) + ex_msg = { class: msg[:exception].class.name, message: msg[:exception].message } + if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL + ex_msg[:backtrace] = msg[:exception].backtrace&.first(5) || [] + end + ex_msg + else + msg[:exception] + end + end + end + + stream.write(log_entry.to_json + "\n") + stream.flush + end + + def cleanup + stream.close + end + + protected + + attr_accessor :stream + + private + + # Keys whose values can be large (full API responses, tool results, etc.) + # and should be truncated at non-DEBUG log levels. + HEAVY_KEYS = %i[result body error].freeze + + # Maximum character length for truncated values. + TRUNCATE_MAX_LENGTH = 1000 + + # Whether the current log level for the MCP source is at least DEBUG + # (LEV_3 / BACKTRACE_LOG_LEVEL), which enables full context output + # and exception backtraces. + # + # @return [Boolean] + def debug_log_level? + get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL + end + + # Return a reduced copy of +ctx+ suitable for non-DEBUG log entries. + # + # Heavy keys (:result, :body, :error) are truncated. The :response sub-hash is also + # truncated. All other keys (scalars like :method, :elapsed_ms, :session_id) pass + # through unchanged. + # + # @param ctx [Hash] The original context hash + # @return [Hash] A summarized copy + def summarize_context(ctx) + return ctx unless ctx.is_a?(Hash) + + ctx.each_with_object({}) do |(k, v), acc| + if HEAVY_KEYS.include?(k) + acc[k] = truncate_value(v) + elsif k == :response && v.is_a?(Hash) + acc[k] = v.each_with_object({}) do |(k_sub, v_sub), acc_sub| + acc_sub[k_sub] = HEAVY_KEYS.include?(k_sub) ? truncate_value(v_sub) : v_sub + end + else + acc[k] = v + end + end + end + + # Truncate a value to a human-readable summary string. + # + # @param val [Object] The value to truncate + # @param max_length [Integer] Maximum character length before truncation + # @return [Object] The original value if short enough, otherwise a truncated string + def truncate_value(val, max_length: TRUNCATE_MAX_LENGTH) + str = val.is_a?(String) ? val : val.to_json + return val if str.length <= max_length + + "#{str[0...max_length]}... (truncated, #{str.length} bytes)" + end + + end + end + end +end diff --git a/lib/msf/core/mcp/logging/sinks/sanitizing.rb b/lib/msf/core/mcp/logging/sinks/sanitizing.rb new file mode 100644 index 0000000000000..666ce5f802104 --- /dev/null +++ b/lib/msf/core/mcp/logging/sinks/sanitizing.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'rex/logging/log_sink' + +module Msf::MCP + module Logging + module Sinks + # A Rex LogSink decorator that redacts sensitive information from log + # messages before delegating to a wrapped sink. + # + # @example Wrapping a JsonFlatfile sink + # inner = Msf::MCP::Logging::Sinks::JsonFlatfile.new('msfmcp.log') + # sink = Msf::MCP::Logging::Sinks::Sanitizing.new(inner) + # register_log_source('mcp', sink, Rex::Logging::LEV_0) + class Sanitizing + include Rex::Logging::LogSink + + REDACTED = '[REDACTED]' + + SENSITIVE_PATTERNS = { + password: /password[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + token_keyval: /token[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + token_header: /token\s+[a-zA-Z0-9_\-\.]+/i, + api_key: /api[_-]?key[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + secret: /secret[_-]?key[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + credential: /credential[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + auth: /auth[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i, + bearer: /bearer\s+[a-zA-Z0-9_\-\.]+/i + }.freeze + + SENSITIVE_KEYS = /\A(password|token|secret|api_key|api_secret|credential|auth_token|bearer|access_token|private_key)\z/i + + # @param sink [Rex::Logging::LogSink] The underlying sink to write to + def initialize(sink) + @sink = sink + end + + def log(sev, src, level, msg) + @sink.log(sev, src, level, sanitize(msg)) + end + + def cleanup + @sink.cleanup + end + + private + + # Sanitize data for logging by redacting sensitive information. + # + # @param data [Object] Data to sanitize (Hash, Array, String, or other) + # @return [Object] Sanitized copy of data + def sanitize(data) + case data + when Hash + 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) + ex_msg = { class: v.class.name, message: sanitize(v.message) } + if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL + bt = v.backtrace&.first(5) || [] + bt = bt.map{|x| x.sub(/^.*lib\//, 'lib/') } # Dont expose the install path + ex_msg[:backtrace] = sanitize(bt) + end + ex_msg + else + sanitize(v) + end + end + when Array + data.map { |item| sanitize(item) } + when String + sanitize_string(data) + else + data + end + end + + # Sanitize a string by redacting sensitive patterns + # + # @param str [String] String to sanitize + # @return [String] Sanitized string + def sanitize_string(str) + return str unless str.is_a?(String) + + sanitized = str.dup + + # Redact sensitive patterns - match entire pattern and replace value part + SENSITIVE_PATTERNS.each do |name, pattern| + sanitized = sanitized.gsub(pattern) do |match| + # For header-style tokens (token abc123, bearer abc123), replace the value + # # TODO: check this + if name == :token_header || name == :bearer + parts = match.split(/\s+/, 2) + "#{parts[0]} #{REDACTED}" + # For key-value style (token: abc123, password=abc123), replace after separator + elsif match =~ /(.*[:=])\s*[\"']?/ + "#{Regexp.last_match[1]} #{REDACTED}" + else + REDACTED + end + end + end + + sanitized + end + + end + end + end +end diff --git a/lib/msf/core/mcp/metasploit/client.rb b/lib/msf/core/mcp/metasploit/client.rb new file mode 100644 index 0000000000000..2d7c91ca2e3c9 --- /dev/null +++ b/lib/msf/core/mcp/metasploit/client.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'forwardable' + +module Msf::MCP + module Metasploit + # Client facade that routes to the appropriate protocol implementation + # Supports MessagePack RPC (Metasploit's native protocol) and JSON-RPC + 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 + + ## + # Initialize Metasploit client with explicit parameters + # + # @param api_type [String] API type: 'messagepack' or 'json-rpc' + # @param host [String] Metasploit host + # @param port [Integer] Metasploit port + # @param endpoint [String] API endpoint path + # @param token [String, nil] API token (for json-rpc) + # @param ssl [Boolean] Use SSL (default: true) + # + def initialize(api_type:, host:, port:, endpoint: nil, token: nil, ssl: true) + @client = create_client(api_type: api_type, host: host, port: port, endpoint: endpoint, token: token, ssl: ssl) + end + + private + + # Create the appropriate client based on API type + # @param api_type [String] API type: 'messagepack' or 'json-rpc' + # @param host [String] Metasploit host + # @param port [Integer] Metasploit port + # @param endpoint [String] API endpoint path + # @param token [String, nil] API token (for json-rpc) + # @param ssl [Boolean] Use SSL (default: true) + # @return [MessagePackClient, JsonRpcClient] Client instance + # @raise [Error] If invalid API type specified + def create_client(api_type:, host:, port:, endpoint: nil, token: nil, ssl: true) + case api_type + when 'messagepack' + require_relative 'messagepack_client' + MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint || MessagePackClient::DEFAULT_ENDPOINT, + ssl: ssl + ) + when 'json-rpc' + require_relative 'jsonrpc_client' + JsonRpcClient.new( + host: host, + port: port, + endpoint: endpoint || JsonRpcClient::DEFAULT_ENDPOINT, + ssl: ssl, + token: token + ) + else + raise Error, "Invalid API type: #{api_type}" + end + end + end + end +end diff --git a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb new file mode 100644 index 0000000000000..ac626859d9224 --- /dev/null +++ b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +module Msf::MCP + module Metasploit + # JSON-RPC 2.0 client for Metasploit Framework + # Implements bearer token authentication for the Metasploit JSON-RPC API + # Endpoint: /api/v1/json-rpc (default port 8081) + # See: lib/msf/core/rpc/json/ in Metasploit Framework repository + class JsonRpcClient + DEFAULT_ENDPOINT = '/api/v1/json-rpc' + + # Initialize JSON-RPC client + # @param host [String] Metasploit RPC host + # @param port [Integer] Metasploit RPC port + # @param endpoint [String] API endpoint path (default: DEFAULT_ENDPOINT) + # @param token [String] Bearer authentication token + # @param ssl [Boolean] Use SSL (default: true) + def initialize(host:, port:, endpoint: DEFAULT_ENDPOINT, token:, ssl: true) + @host = host + @port = port + @endpoint = endpoint + @token = token + @request_id = 0 + @http = nil + @ssl = ssl + end + + # No-op for JSON-RPC: authentication uses a pre-configured bearer token. + # This method exists so that JsonRpcClient satisfies the same interface as + # MessagePackClient, allowing the Client facade to delegate uniformly. + # + # @param _user [String] Ignored + # @param _password [String] Ignored + # @return [String] The existing token + def authenticate(_user, _password) + @token + end + + # Call Metasploit API method using JSON-RPC 2.0 format + # @param method [String] API method name + # @param args [Array] Arguments to pass to the method (must be an array) + # @return [Hash] API response + # @raise [AuthenticationError] If token is invalid + # @raise [APIError] If API returns error + # @raise [ConnectionError] If connection fails + # @raise [ArgumentError] If args is not an array + def call_api(method, args = []) + raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array) + + @request_id += 1 + + # Build JSON-RPC 2.0 request as a hash + request_body = { + jsonrpc: '2.0', + method: method, + params: args, + id: @request_id + } + + # Send HTTP request + response = send_request(request_body) + + # Check for JSON-RPC error + if response['error'] + error_msg = response['error']['message'] || 'Unknown error' + raise APIError, error_msg + end + + response['result'] + end + + # Search for Metasploit modules + # @param query [String] Search query + # @return [Array] Module metadata + def search_modules(query) + call_api('module.search', [query]) + end + + # Get module information + # @param type [String] Module type ('exploit', 'auxiliary', 'post', etc.) + # @param name [String] Module name + # @return [Hash] Module information + def module_info(type, name) + call_api('module.info', [type, name]) + end + + # Get hosts from database + # @param options [Hash] Query options (workspace, limit, offset, etc.) + # @return [Hash] Response with 'hosts' array + def db_hosts(options = {}) + call_api('db.hosts', [options]) + end + + # Get services from database + # @param options [Hash] Query options + # @return [Hash] Response with 'services' array + def db_services(options = {}) + call_api('db.services', [options]) + end + + # Get vulnerabilities from database + # @param options [Hash] Query options + # @return [Hash] Response with 'vulns' array + def db_vulns(options = {}) + call_api('db.vulns', [options]) + end + + # Get notes from database + # @param options [Hash] Query options + # @return [Hash] Response with 'notes' array + def db_notes(options = {}) + call_api('db.notes', [options]) + end + + # Get credentials from database + # @param options [Hash] Query options + # @return [Hash] Response with 'creds' array + def db_creds(options = {}) + call_api('db.creds', [options]) + end + + # Get loot from database + # @param options [Hash] Query options + # @return [Hash] Response with 'loots' array + def db_loot(options = {}) + call_api('db.loots', [options]) + end + + # Shutdown client + def shutdown + @http&.finish if @http&.started? + @http = nil + end + + private + + # Send HTTP POST request with JSON-RPC payload + # @param request_body [Hash] JSON-RPC request body as a hash + # @return [Hash] Parsed response + # @raise [ConnectionError] If connection fails + # @raise [AuthenticationError] If token is invalid + def send_request(request_body) + # Create HTTP client if needed + unless @http + @http = Net::HTTP.new(@host, @port) + @http.use_ssl = @ssl + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl + end + + # Create POST request + request = Net::HTTP::Post.new(@endpoint) + request['Content-Type'] = 'application/json' + request['Authorization'] = "Bearer #{@token}" + request.body = request_body.to_json + + dlog({ + message: 'JSON-RPC request', + context: { method: request.method, endpoint: @endpoint, body: request_body } + }, LOG_SOURCE, LOG_DEBUG) + + # Send request and parse response + begin + response = @http.request(request) + + parsed = case response.code.to_i + when 200 + JSON.parse(response.body) + when 401 + raise AuthenticationError, 'Invalid authentication token' + when 500 + error_data = JSON.parse(response.body) rescue { 'error' => { 'message' => 'Internal server error' } } + error_msg = error_data.dig('error', 'message') || 'Internal server error' + raise APIError, error_msg + else + raise ConnectionError, "HTTP #{response.code}: #{response.message}" + end + + dlog({ + message: 'JSON-RPC response', + context: { status: response.code, body: parsed } + }, LOG_SOURCE, LOG_DEBUG) + + parsed + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e + raise ConnectionError, "Cannot connect to Metasploit RPC: #{e.message}" + rescue SocketError => e + raise ConnectionError, "Network error: #{e.message}" + rescue Timeout::Error => e + raise ConnectionError, "Request timeout: #{e.message}" + rescue EOFError => e + raise ConnectionError, "Empty response from Metasploit RPC: #{e.message}" + end + end + end + end +end diff --git a/lib/msf/core/mcp/metasploit/messagepack_client.rb b/lib/msf/core/mcp/metasploit/messagepack_client.rb new file mode 100644 index 0000000000000..ef96c99b1fe49 --- /dev/null +++ b/lib/msf/core/mcp/metasploit/messagepack_client.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require 'net/http' +require 'msgpack' + +module Msf::MCP + module Metasploit + # MessagePack RPC client for Metasploit Framework + # Implements authentication and API calls using MessagePack serialization + class MessagePackClient + DEFAULT_ENDPOINT = '/api/' + + # Initialize MessagePack client + # @param host [String] Metasploit RPC host + # @param port [Integer] Metasploit RPC port + # @param endpoint [String] API endpoint path (default: DEFAULT_ENDPOINT) + # @param ssl [Boolean] Use SSL (default: true) + def initialize(host:, port:, endpoint: DEFAULT_ENDPOINT, ssl: true) + @host = host + @port = port + @endpoint = endpoint + @token = nil + @http = nil + @user = nil + @password = nil + @retry_count = 0 + @max_retries = 2 + @ssl = ssl + end + + # Authenticate with Metasploit RPC + # @param user [String] Username + # @param password [String] Password + # @return [String] The resulting token if authentication successful + # @raise [AuthenticationError] If authentication fails + def authenticate(user, password) + # Store credentials for automatic re-authentication + @user = user + @password = password + + # Send authentication request directly (bypass retry logic) + request_array = ['auth.login', user, password] + response = send_request(request_array) + + # Real Metasploit API returns string keys + if response['result'] == 'success' && response['token'] + @token = response['token'] + elsif response['error'] + raise AuthenticationError, response['error'] + else + raise AuthenticationError, 'Authentication failed' + end + end + + # Call Metasploit RPC API method + # @param method [String] API method name (e.g., 'module.search') + # @param args [Array] Arguments to pass to the method (must be an array) + # @return [Hash, Array] API response + # @raise [AuthenticationError] If authentication fails + # @raise [APIError] If API returns an error + # @raise [ConnectionError] If connection fails + # @raise [ArgumentError] If args is not an array + def call_api(method, args = []) + raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array) + + begin + raise AuthenticationError, 'Not authenticated' unless @token + + # Build request array: [method, token, *args] + request_array = [method, @token, *args] + + # Send HTTP request + send_request(request_array) + + rescue AuthenticationError => e + # It is not possible to reauthenticate if we don't have credentials stored + raise unless @user && @password + # If reauthentication succeeded but the token is still invalid, we should not retry indefinitely + raise unless @retry_count < @max_retries + + @retry_count += 1 + @token = nil + + begin + wlog({ message: "#{method}': #{e.message}. Attempting to re-authenticate (#{@retry_count}/#{@max_retries})" }, + LOG_SOURCE, LOG_WARN) + authenticate(@user, @password) + rescue AuthenticationError => auth_e + wlog({ message: "Re-authentication failed: #{auth_e.message}" }, + LOG_SOURCE, LOG_WARN) + if @retry_count < @max_retries + @retry_count += 1 + @token = nil + retry + end + raise AuthenticationError, "Unable to authenticate after #{@retry_count} attempts: #{auth_e.message}" + end + + # Retry the original request with new token + retry + end + + rescue Msf::MCP::Error => e + elog({ message: 'MessagePack API call error', context: { error: e.message } }, + LOG_SOURCE, LOG_ERROR) + raise + ensure + @retry_count = 0 + end + + # Search for Metasploit modules + # @param query [String] Search query + # @return [Array] Module metadata + def search_modules(query) + call_api('module.search', [query]) + end + + # Get module information + # @param type [String] Module type ('exploit', 'auxiliary', 'post', etc.) + # @param name [String] Module name + # @return [Hash] Module information + def module_info(type, name) + call_api('module.info', [type, name]) + end + + # Get hosts from database + # @param options [Hash] Query options (workspace, limit, offset, etc.) + # @return [Hash] Response with 'hosts' array + def db_hosts(options = {}) + call_api('db.hosts', [options]) + end + + # Get services from database + # @param options [Hash] Query options + # @return [Hash] Response with 'services' array + def db_services(options = {}) + call_api('db.services', [options]) + end + + # Get vulnerabilities from database + # @param options [Hash] Query options + # @return [Hash] Response with 'vulns' array + def db_vulns(options = {}) + call_api('db.vulns', [options]) + end + + # Get notes from database + # @param options [Hash] Query options + # @return [Hash] Response with 'notes' array + def db_notes(options = {}) + call_api('db.notes', [options]) + end + + # Get credentials from database + # @param options [Hash] Query options + # @return [Hash] Response with 'creds' array + def db_creds(options = {}) + call_api('db.creds', [options]) + end + + # Get loot from database + # @param options [Hash] Query options + # @return [Hash] Response with 'loots' array + def db_loot(options = {}) + call_api('db.loots', [options]) + end + + # Shutdown client and cleanup + def shutdown + @token = nil + @user = nil + @password = nil + @http&.finish if @http&.started? + @http = nil + end + + private + + # Send HTTP POST request with MessagePack payload + # @param request_array [Array] Request data + # @return [Hash, Array] Parsed response + # @raise [AuthenticationError] If the token is not valid + # @raise [APIError] If the Metasploit API returns an error + # @raise [ConnectionError] If connection fails + def send_request(request_array) + # Create HTTP client if needed + unless @http + @http = Net::HTTP.new(@host, @port) + @http.use_ssl = @ssl + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl + end + + # Encode request with MessagePack + request_body = request_array.to_msgpack + + # Create POST request + request = Net::HTTP::Post.new(@endpoint) + request['Content-Type'] = 'binary/message-pack' + request.body = request_body + + dlog({ + message: 'MessagePack request', + context: { method: request.method, endpoint: @endpoint, body: sanitize_request_array(request_array) } + }, LOG_SOURCE, LOG_DEBUG) + + # Send request and parse response + begin + response = @http.request(request) + + parsed = case response.code.to_i + when 200 + MessagePack.unpack(response.body) + when 401 + error_data = MessagePack.unpack(response.body) rescue { 'error_message' => 'Authentication error' } + error_msg = error_data['error_message'] || error_data['error_string'] || 'Authentication error' + raise AuthenticationError, error_msg + when 500 + error_data = MessagePack.unpack(response.body) rescue { 'error_message' => 'Internal server error' } + error_msg = error_data['error_message'] || error_data['error_string'] || 'Internal server error' + raise APIError, error_msg + else + raise ConnectionError, "HTTP #{response.code}: #{response.message}" + end + + dlog({ + message: 'MessagePack response', + context: { status: response.code, body: parsed } + }, LOG_SOURCE, LOG_DEBUG) + + parsed + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e + raise ConnectionError, "Cannot connect to Metasploit RPC: #{e.message}" + rescue SocketError => e + raise ConnectionError, "Network error: #{e.message}" + rescue Timeout::Error => e + raise ConnectionError, "Request timeout: #{e.message}" + rescue EOFError => e + raise ConnectionError, "Empty response from Metasploit RPC: #{e.message}" + end + end + + REDACTED = '[REDACTED]' + + # Sanitize request array for logging by redacting sensitive positional values + # + # For auth.login requests: redacts the password (last element) + # For API calls: redacts the token (second element) + # + # @param request_array [Array] Raw request array + # @return [Array] Sanitized copy with sensitive values redacted + def sanitize_request_array(request_array) + sanitized = request_array.dup + if sanitized[0] == 'auth.login' + sanitized[-1] = REDACTED + elsif sanitized.length > 1 + sanitized[1] = REDACTED + end + sanitized + end + end + end +end diff --git a/lib/msf/core/mcp/metasploit/response_transformer.rb b/lib/msf/core/mcp/metasploit/response_transformer.rb new file mode 100644 index 0000000000000..4a6999aadbc14 --- /dev/null +++ b/lib/msf/core/mcp/metasploit/response_transformer.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'time' + +module Msf::MCP + module Metasploit + # Transforms Metasploit RPC responses into MCP-compatible format + # Adds metadata, converts field names, and formats timestamps + class ResponseTransformer + # Transform module search results + # @param modules [Array] Raw module data from Metasploit + # @return [Array] Transformed modules with MCP metadata + def self.transform_modules(modules) + return [] unless modules.is_a?(Array) + + modules.map do |mod| + { + name: mod['name'] || mod['fullname'], + type: mod['type'], + fullname: mod['fullname'], + rank: mod['rank'], + disclosure_date: mod['disclosuredate'] + }.compact + end + end + + # Transform module info response + # @param info [Hash] Raw module info from Metasploit + # @return [Hash] Transformed info with MCP metadata + def self.transform_module_info(info) + return {} unless info.is_a?(Hash) + + { + type: info['type'], + name: info['name'], + fullname: info['fullname'], + rank: info['rank'], + disclosure_date: info['disclosuredate'], + description: info['description'], + license: info['license'], + filepath: info['filepath']&.sub(/^.*modules\//, 'modules/'), # Dont expose the install path + architectures: info['arch'], + platforms: info['platform'], + authors: info['authors'], + privileged: info['privileged'], + has_check_method: info['check'], + # TODO: write transformer for default_options + default_options: info['default_options'], + references: transform_references(info['references']), + targets: info['targets'], + default_target: info['default_target'], + stance: info['stance'], + actions: info['actions'], + default_action: info['default_action'], + # TODO: write transformer for options + options: info['options'] + }.compact + end + + # Transform hosts response + # @param response [Hash] Raw response with 'hosts' array + # @return [Array] Transformed hosts with MCP metadata + def self.transform_hosts(response) + return [] unless response.is_a?(Hash) && response['hosts'].is_a?(Array) + + response['hosts'].map do |host| + { + created_at: format_timestamp(host['created_at']), + address: host['address'], + mac_address: host['mac'], + hostname: host['name'], + state: host['state'], + os_name: host['os_name'], + os_flavor: host['os_flavor'], + os_service_pack: host['os_sp'], + os_language: host['os_lang'], + updated_at: format_timestamp(host['updated_at']), + purpose: host['purpose'], + info: host['info'] + }.compact + end + end + + # Transform services response + # @param response [Hash] Raw response with 'services' array + # @return [Array] Transformed services + def self.transform_services(response) + return [] unless response.is_a?(Hash) && response['services'].is_a?(Array) + + response['services'].map do |service| + { + host_address: service['host'], + created_at: format_timestamp(service['created_at']), + updated_at: format_timestamp(service['updated_at']), + port: service['port'], + protocol: service['proto'], + state: service['state'], + name: service['name'], + info: service['info'], + }.compact + end + end + + # Transform vulnerabilities response + # @param response [Hash] Raw response with 'vulns' array + # @return [Array] Transformed vulnerabilities + def self.transform_vulns(response) + return [] unless response.is_a?(Hash) && response['vulns'].is_a?(Array) + + response['vulns'].map do |vuln| + { + host: vuln['host'], + port: vuln['port'], + protocol: vuln['proto'], + name: vuln['name'], + references: parse_refs(vuln['refs']), + created_at: format_timestamp(vuln['time']) + }.compact + end + end + + # Transform notes response + # @param response [Hash] Raw response with 'notes' array + # @return [Array] Transformed notes + def self.transform_notes(response) + return [] unless response.is_a?(Hash) && response['notes'].is_a?(Array) + + response['notes'].map do |note| + { + host: note['host'], + service_name_or_port: note['service'], + note_type: note['type'] || note['ntype'], + data: note['data'], + created_at: format_timestamp(note['time']) + }.compact + end + end + + # Transform credentials response + # @param response [Hash] Raw response with 'creds' array + # @return [Array] Transformed credentials + def self.transform_creds(response) + return [] unless response.is_a?(Hash) && response['creds'].is_a?(Array) + + response['creds'].map do |cred| + { + host: cred['host'], + port: cred['port'], + protocol: cred['proto'], + service_name: cred['sname'], + user: cred['user'], + secret: cred['pass'], + type: cred['type'], + updated_at: format_timestamp(cred['updated_at']) + }.compact + end + end + + # Transform loot response + # @param response [Hash] Raw response with 'loots' array + # @return [Array] Transformed loot + def self.transform_loot(response) + return [] unless response.is_a?(Hash) && response['loots'].is_a?(Array) + + response['loots'].map do |loot| + { + host: loot['host'], + service_name_or_port: loot['service'], + loot_type: loot['ltype'], + content_type: loot['ctype'], + name: loot['name'], + info: loot['info'], + data: loot['data'], + created_at: format_timestamp(loot['created_at']), + updated_at: format_timestamp(loot['updated_at']) + }.compact + end + end + + private + + # Convert Unix epoch timestamp to ISO 8601 format + # @param timestamp [Integer, nil] Unix timestamp + # @return [String, nil] ISO 8601 formatted string + def self.format_timestamp(timestamp) + return nil if timestamp.nil? || timestamp.to_i.zero? + Time.at(timestamp.to_i).utc.iso8601 + end + + # Transform references array + # @param refs [Array, nil] References from Metasploit + # @return [Array, nil] Transformed references + def self.transform_references(refs) + return nil unless refs.is_a?(Array) + + refs.map do |ref| + if ref.is_a?(Array) && ref.length == 2 + { type: ref[0], value: ref[1] } + else + ref + end + end + end + + # Parse comma-separated reference string + # Note there can have some issues if the ref values themselves contain commas, + # but it is the way the MSF RPC API returns them. + # @param refs [String, nil] Comma-separated refs + # @return [Array, nil] Array of references + def self.parse_refs(refs) + return nil if refs.nil? || refs.empty? + refs.to_s.split(',').map(&:strip).reject(&:empty?) + end + end + end +end diff --git a/lib/msf/core/mcp/middleware/request_logger.rb b/lib/msf/core/mcp/middleware/request_logger.rb new file mode 100644 index 0000000000000..d2154d3bcb2bb --- /dev/null +++ b/lib/msf/core/mcp/middleware/request_logger.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module Msf::MCP + module Middleware + ## + # Rack middleware that logs MCP HTTP request/response details via Rex logging. + # + # Focuses on the HTTP transport layer: request method, status code, session ID, + # content type, and round-trip timing. For POST requests it also extracts + # JSON-RPC fields (method, id, params) and response result/error to provide + # DEBUG-level visibility into the exchange. + # + # MCP-level business details (tool names, tool durations, and structured + # results) are handled by the SDK's +around_request+ callback configured + # in Server, avoiding duplication. + # + # @example Usage in a Rack::Builder + # Rack::Builder.new do + # use Msf::MCP::Middleware::RequestLogger + # run transport + # end + # + class RequestLogger + + ## + # @param app [#call] The next Rack application in the middleware stack + # + def initialize(app) + @app = app + end + + ## + # Process the request, delegating to the next Rack app and logging + # transport-level details after the response is produced. + # + # @param env [Hash] The Rack environment + # @return [Array] The Rack response triplet [status, headers, body] + # + def call(env) + request = Rack::Request.new(env) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + response = @app.call(env) + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + log_exchange(request, response, elapsed) + + response + end + + private + + ## + # Log a single request/response entry at the HTTP transport level. + # + # Dispatches to {#log_post_exchange} for POST requests (which extracts + # JSON-RPC fields). GET, DELETE, and other methods are logged directly + # with status and timing information. + # + # @param request [Rack::Request] The incoming HTTP request + # @param response [Array] The Rack response [status, headers, body] + # @param elapsed [Float] Wall-clock seconds for the round-trip + # + def log_exchange(request, response, elapsed) + status, headers, _body = response + session_id = request.env['HTTP_MCP_SESSION_ID'] || headers&.fetch('Mcp-Session-Id', nil) + elapsed_ms = (elapsed * 1000).round(2) + + context = { elapsed_ms: elapsed_ms } + context[:session_id] = session_id if session_id + + case request.request_method + when 'POST' + log_post_exchange(request, response, context) + when 'GET' + context[:response] = build_response_context(response) + ilog({ message: "SSE stream opened (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_INFO) + when 'DELETE' + context[:response] = build_response_context(response) + ilog({ message: "Session deleted (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_INFO) + else + context[:response] = build_response_context(response) + dlog({ message: "HTTP #{request.request_method} #{status} (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_DEBUG) + end + end + + ## + # Log a POST exchange with JSON-RPC params and response result/error + # nested under :request and :response keys. + # + # For streaming responses (Proc body), the result is not available here — + # it is logged by the +around_request+ callback in Server instead. + # + # Distinguishes between: + # - Notifications (no id): logged at DEBUG since the SDK instrumentation + # does not fire for these + # - Requests with HTTP errors: logged at ERROR with the error details + # - Normal requests: logged at DEBUG with params and result + # (the +around_request+ callback provides the INFO-level business log) + # + # @param request [Rack::Request] The incoming HTTP request + # @param response [Array] The Rack response [status, headers, body] + # @param context [Hash] Pre-built context hash with session_id and elapsed_ms + # + def log_post_exchange(request, response, context) + context[:request] = {} + jsonrpc = extract_jsonrpc_fields(request) + if jsonrpc + context[:request][:method] = jsonrpc[:method] if jsonrpc[:method] + context[:request][:id] = jsonrpc[:id] if jsonrpc[:id] + context[:request][:params] = jsonrpc[:params] if jsonrpc[:params] + end + + context[:response] = build_response_context(response) + response_body = extract_response_body(response) + if response_body + context[:response][:result] = response_body[:result] if response_body[:result] + context[:response][:error] = response_body[:error] if response_body[:error] + end + + method_name = context[:request][:method] || 'unknown' + if context[:request][:id].nil? && context[:request][:method] + # Notification — no instrumentation fires for these + dlog({ message: "Notification: #{method_name} #{context[:response][:status]} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_DEBUG) + elsif context[:response][:status] >= 400 + elog({ message: "HTTP #{context[:response][:status]}: #{method_name} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_ERROR) + else + dlog({ message: "HTTP #{context[:response][:status]}: #{method_name} id=#{context[:request][:id]} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_DEBUG) + end + end + + ## + # Build the response portion of the log context from the Rack response. + # + # @param response [Array] The Rack response [status, headers, body] + # @return [Hash] Response context with :status and :content_type + # + def build_response_context(response) + status, headers, _body = response + res = { status: status } + res[:content_type] = headers['Content-Type'] if headers&.key?('Content-Type') + res + end + + ## + # Extract JSON-RPC method, id, and params from the request body. + # + # Rewinds before and after reading so downstream handlers can still + # consume the body. + # + # @param request [Rack::Request] The incoming HTTP request + # @return [Hash, nil] Parsed fields (:method, :id, :params), or nil on + # parse failure + # + def extract_jsonrpc_fields(request) + request.body.rewind + body = request.body.read + request.body.rewind + parsed = JSON.parse(body) + { method: parsed['method'], id: parsed['id'], params: parsed['params'] } + rescue JSON::ParserError + nil + end + + ## + # Extract result or error from the response body. + # + # Only parses Array bodies (direct JSON responses). SSE stream responses + # (Proc bodies) are not parseable here — their results are logged by the + # +around_request+ callback in Server. + # + # @param response [Array] The Rack response [status, headers, body] + # @return [Hash, nil] Parsed fields (:result, :error), or nil if the body + # is empty, non-Array, or unparseable + # + def extract_response_body(response) + _status, _headers, body = response + return nil unless body.is_a?(Array) && !body.empty? + + parsed = JSON.parse(body.first) + { result: parsed['result'], error: parsed['error'] } + rescue JSON::ParserError + nil + end + end + end +end diff --git a/lib/msf/core/mcp/rpc_manager.rb b/lib/msf/core/mcp/rpc_manager.rb new file mode 100644 index 0000000000000..ee37460412f50 --- /dev/null +++ b/lib/msf/core/mcp/rpc_manager.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'socket' + +module Msf::MCP + # Manages the lifecycle of a Metasploit RPC server process. + # + # Probes the configured RPC port, auto-starts the server via Process.spawn + # of msfrpcd, and cleans up the child process on shutdown. + class RpcManager + LOCALHOST_HOSTS = %w[localhost 127.0.0.1 ::1].freeze + DEFAULT_WAIT_TIMEOUT = 30 + DEFAULT_WAIT_INTERVAL = 1 + STOP_GRACE_PERIOD = 5 + + attr_reader :rpc_pid + + # @param config [Hash] Application configuration hash + # @param output [IO] Output stream for status messages + def initialize(config:, output:) + @config = config + @output = output + @rpc_pid = nil + @rpc_managed = false + end + + # Whether this manager started and is managing an RPC server process. + # + # @return [Boolean] + def rpc_managed? + @rpc_managed + end + + # Probe the configured RPC port to check if a server is listening. + # + # @return [Boolean] + def rpc_available? + host = @config[:msf_api][:host] + port = @config[:msf_api][:port] + + socket = Rex::Socket::Tcp.create( + 'PeerHost' => host, + 'PeerPort' => port + ) + socket.close + dlog({ message: "RPC server is available at #{Rex::Socket.to_authority(host, port)}" }, + LOG_SOURCE, LOG_DEBUG) + true + rescue Rex::ConnectionError + false + end + + # Whether auto-start is enabled based on config, API type, and host. + # + # Auto-start is only supported for: + # - MessagePack API type (not JSON-RPC) + # - Localhost connections (cannot start a remote RPC server) + # - When auto_start_rpc config is not explicitly false + # + # @return [Boolean] + def auto_start_enabled? + return false if @config[:msf_api][:type] != 'messagepack' + return false unless localhost? + return false if @config[:msf_api][:auto_start_rpc] == false + + true + end + + # Start the Metasploit RPC server by spawning msfrpcd. + # + # Credentials are passed via environment variables to avoid exposing + # them on the command line. + # + # @return [void] + # @raise [Msf::MCP::Metasploit::RpcStartupError] If the server cannot be started + def start_rpc_server + if @rpc_managed + @output.puts 'RPC server is already managed by this process' + return + end + + @output.puts 'Starting Metasploit RPC server...' + ilog({ message: 'Starting Metasploit RPC server' }, + LOG_SOURCE, LOG_INFO) + + unless File.executable?(MSFRPCD_PATH) + raise Msf::MCP::Metasploit::RpcStartupError, + 'msfrpcd executable not found. Cannot auto-start RPC server.' + end + + args = build_msfrpcd_args + env = { + 'MSF_RPC_USER' => @config[:msf_api][:user].to_s, + 'MSF_RPC_PASS' => @config[:msf_api][:password].to_s + } + + pid = Process.spawn(env, MSFRPCD_PATH, *args, %i[out err] => File::NULL) + + @rpc_pid = pid + @rpc_managed = true + @output.puts "RPC server started via msfrpcd (PID: #{pid})" + end + + # Wait for the RPC server to become available. + # + # @param timeout [Integer] Maximum seconds to wait (default: 30) + # @param interval [Integer] Seconds between probes (default: 1) + # @return [true] When the server becomes available + # @raise [Msf::MCP::Metasploit::ConnectionError] If timeout is reached + # @raise [Msf::MCP::Metasploit::RpcStartupError] If the managed process exits + def wait_for_rpc(timeout: DEFAULT_WAIT_TIMEOUT, interval: DEFAULT_WAIT_INTERVAL) + deadline = Time.now + timeout + + loop do + if rpc_available? + @output.puts 'RPC server is ready' + return true + end + + check_managed_process_alive! if @rpc_managed + + if Time.now >= deadline + raise Msf::MCP::Metasploit::ConnectionError, + "Timed out waiting for RPC server after #{timeout} seconds" + end + + @output.puts 'Waiting for RPC server to become available...' + sleep(interval) + end + end + + # Stop the managed RPC server process. + # + # @return [void] + def stop_rpc_server + return unless @rpc_managed + + @output.puts 'Stopping managed RPC server...' + ilog({ message: "Stopping managed RPC server (PID: #{@rpc_pid})" }, + LOG_SOURCE, LOG_INFO) + + begin + Process.kill('TERM', @rpc_pid) + graceful_wait + rescue Errno::ESRCH + # Process already dead — that's fine + rescue Errno::EPERM + @output.puts "Warning: no permission to stop RPC process #{@rpc_pid}" + end + + @rpc_pid = nil + @rpc_managed = false + end + + # Ensure an RPC server is available, auto-starting if needed. + # + # When the RPC server is already listening, verifies that credentials + # (or a token for JSON-RPC) are available for the caller to authenticate. + # + # When the server is not available, auto-start is attempted only for + # MessagePack on localhost with auto_start_rpc enabled. Random + # credentials are generated when none are provided. + # + # @return [void] + # @raise [Msf::MCP::Metasploit::RpcStartupError] If the server cannot be + # reached and auto-start is not possible, or if the server is running + # but no credentials/token were provided + def ensure_rpc_available + if rpc_available? + @output.puts 'Metasploit RPC server is already running' + validate_credentials_for_existing_server! + return + end + + if @config[:msf_api][:type] == 'json-rpc' + raise Msf::MCP::Metasploit::RpcStartupError, + 'RPC server is not running and auto-start is not supported for JSON-RPC API type.' + end + + unless localhost? + message = "RPC server is not available at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]}." + message << ' Cannot auto-start RPC on remote hosts. Please start the RPC server manually.' if auto_start_enabled? + raise Msf::MCP::Metasploit::RpcStartupError, message + end + + unless auto_start_enabled? + raise Msf::MCP::Metasploit::RpcStartupError, + "RPC server is not running on #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]} " \ + 'and auto-start is disabled.' + end + + generate_random_credentials unless credentials_provided? + start_rpc_server + wait_for_rpc + end + + private + + # Absolute path to msfrpcd relative to the framework root. + MSFRPCD_PATH = File.join(__dir__, '../../../..', 'msfrpcd').freeze + + # Build command-line arguments for msfrpcd. + # + # Note: credentials are passed via environment variables (MSF_RPC_USER, + # MSF_RPC_PASS) rather than command-line arguments for security. + # + # @return [Array] + def build_msfrpcd_args + args = ['-f'] # foreground mode + args.push('-a', @config[:msf_api][:host].to_s) + args.push('-p', @config[:msf_api][:port].to_s) + args.push('-S') if @config[:msf_api][:ssl] == false + args + end + + # Check whether the host is a localhost address. + # + # @return [Boolean] + def localhost? + LOCALHOST_HOSTS.include?(@config[:msf_api][:host].to_s.downcase) + end + + # Whether both user and password are present in the configuration. + # + # @return [Boolean] + def credentials_provided? + user = @config[:msf_api][:user] + password = @config[:msf_api][:password] + !user.to_s.strip.empty? && !password.to_s.strip.empty? + end + + # Whether the BEARER token is present in the configuration. + # + # @return [Boolean] + def token_provided? + token = @config[:msf_api][:token] + !token.to_s.strip.empty? + end + + # Verify that the caller has credentials to authenticate with an + # already-running RPC server. For MessagePack this means user+password; + # for JSON-RPC this means a bearer token. + # + # @raise [Msf::MCP::Metasploit::RpcStartupError] If required credentials + # are missing + def validate_credentials_for_existing_server! + if @config[:msf_api][:type] == 'json-rpc' + return if token_provided? + + raise Msf::MCP::Metasploit::RpcStartupError, + 'RPC server is already running but no token was provided. ' \ + 'Use --token option or MSF_API_TOKEN environment variable.' + else + return if credentials_provided? + + raise Msf::MCP::Metasploit::RpcStartupError, + 'RPC server is already running but no credentials were provided. ' \ + 'Use --user and --password options or MSF_API_USER and MSF_API_PASSWORD environment variables.' + end + end + + # Generate random credentials and write them into the config hash. + # + # @return [void] + def generate_random_credentials + @config[:msf_api][:user] = SecureRandom.hex(8) + @config[:msf_api][:password] = SecureRandom.hex(16) + @output.puts 'Generated random credentials for auto-started RPC server' + ilog({ message: 'Generated random credentials for auto-started RPC server' }, + LOG_SOURCE, LOG_INFO) + end + + # Check if the managed child process is still alive. + # Raises RpcStartupError if it has exited. + def check_managed_process_alive! + return unless @rpc_pid + + result = Process.waitpid(@rpc_pid, Process::WNOHANG) + return unless result + + @rpc_pid = nil + @rpc_managed = false + raise Msf::MCP::Metasploit::RpcStartupError, 'RPC server process exited unexpectedly' + end + + # Wait for the child process to exit after SIGTERM, escalating to + # SIGKILL if it does not exit within the grace period. + def graceful_wait + result = Process.waitpid(@rpc_pid, Process::WNOHANG) + return if result + + sleep(STOP_GRACE_PERIOD) + result = Process.waitpid(@rpc_pid, Process::WNOHANG) + return if result + + # Process did not exit; escalate to SIGKILL + Process.kill('KILL', @rpc_pid) + Process.waitpid(@rpc_pid, 0) + end + end +end diff --git a/lib/msf/core/mcp/security/input_validator.rb b/lib/msf/core/mcp/security/input_validator.rb new file mode 100644 index 0000000000000..7b047f52b2f9b --- /dev/null +++ b/lib/msf/core/mcp/security/input_validator.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'ipaddr' + +module Msf::MCP + module Security + class InputValidator + LIMIT_DEFAULT = 100 + LIMIT_MIN = 1 + LIMIT_MAX = 1000 + + # Generic parameter validation against a constraint + # + # Dispatches based on the constraint type: + # - Array → value must be included in the list (enum) + # - Range → value must be an integer within the range, or a Range whose + # bounds are within the constraint (range must be integer-bounded) + # - Regexp → value (via .to_s) must match the pattern + # + # @param name [String] Parameter name (used in error messages) + # @param value [Object] Value to validate + # @param constraint [Array, Range, Regexp] Allowed values, range, or pattern + # @param allow_nil [Boolean] Whether nil/empty values are allowed (default: false) + # @param max_size [Integer] (optional) Maximum length for string values (only applies to Regexp constraints) + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_parameter!(name, value, constraint, allow_nil: false, max_size: nil) + if allow_nil + return true if value.nil? + return true if value.respond_to?(:empty?) && value.empty? + else + raise ValidationError, "#{name} cannot be nil" if value.nil? + raise ValidationError, "#{name} cannot be empty" if value.respond_to?(:empty?) && value.empty? + end + + case constraint + when Array + unless constraint.include?(value) + raise ValidationError, "Invalid #{name}: #{value.inspect}. Must be one of: #{constraint.join(', ')}" + end + when Range + unless constraint.first.is_a?(Integer) && constraint.last.is_a?(Integer) + raise ArgumentError, "Range constraint must be a range of integers, got #{constraint.first.class}..#{constraint.last.class}" + end + if value.is_a?(Range) + begin + int_first = Integer(value.first) + int_last = Integer(value.last) + rescue TypeError, ArgumentError + raise ValidationError, "#{name} must have integer bounds: #{value.inspect}" + end + unless constraint.cover?(int_first..int_last) + raise ValidationError, "#{name} must be between #{constraint.min} and #{constraint.max}: #{int_first}..#{int_last}" + end + else + begin + int_value = Integer(value) + rescue TypeError, ArgumentError + raise ValidationError, "#{name} must be an integer: #{value.inspect}" + end + unless constraint.cover?(int_value) + raise ValidationError, "#{name} must be between #{constraint.min} and #{constraint.max}: #{value}" + end + end + when Regexp + string_value = value.to_s + if max_size && string_value.length > max_size + raise ValidationError, "#{name} too long (max #{max_size} characters)" + end + unless string_value.match?(constraint) + raise ValidationError, "Invalid #{name} format: #{value}" + end + else + raise ArgumentError, "Unsupported constraint type: #{constraint.class}" + end + + true + end + + # Validate IP address or CIDR range + # + # @param addr [String] IP address or CIDR (e.g., "192.168.1.1" or "192.168.1.0/24") + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_ip_address!(addr) + return true if addr.nil? || addr.empty? + + begin + IPAddr.new(addr) + true + rescue IPAddr::InvalidAddressError + raise ValidationError, "Invalid IP address or CIDR: #{addr}" + end + end + + # Validate port or port range + # + # @param range [String, Integer] Port number or range (e.g., "80" or "80-443") + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_port_range!(range) + return true if range.nil? || range.to_s.empty? + + range_str = range.to_s + + # Match a port range like "80-443" — requires digits on both sides of the dash + if range_str.match?(/\A\s*[[:alnum:]]+-[[:alnum:]]+\s*\z/) + begin + start_port, end_port = range_str.split('-', 2).map { |p| Integer(p.strip) } + rescue TypeError, ArgumentError + raise ValidationError, "Port range must have integer bounds: #{range_str}" + end + validate_parameter!('Port range', start_port..end_port, 1..65535) + else + validate_parameter!('Port', range_str, 1..65535) + end + + true + end + + # Validate query string for module search + # + # @param query [String] Search query + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_search_query!(query) + validate_parameter!('Search query', query, /\A[[:print:]]+\z/, allow_nil: false, max_size: 500) + end + + # Validate limit parameter for pagination + # + # @param limit [Integer] Limit value + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_limit!(limit) + validate_parameter!('Limit', limit, LIMIT_MIN..LIMIT_MAX, allow_nil: true) + end + + # Validate offset parameter for pagination + # + # @param offset [Integer] Offset value + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_offset!(offset) + validate_parameter!('Offset', offset, 0..LIMIT_MAX, allow_nil: true) + end + + # Validate pagination parameters + # + # @param limit [Integer] Limit value + # @param offset [Integer] Offset value + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_pagination!(limit, offset) + validate_limit!(limit) + validate_offset!(offset) + end + + # Validate module type + # + # @param module_type [String] Module type + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_module_type!(module_type) + validate_parameter!('Module type', module_type, %w[exploit auxiliary post payload encoder evasion nop]) + end + + # Validate module name + # + # @param module_name [String] Module name/path + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_module_name!(module_name) + # Basic path validation (alphanumeric, slashes, underscores, hyphens) + validate_parameter!('Module name', module_name, %r{\A[\w/\-]+\z}, max_size: 500) + end + + # Validate only_up boolean parameter + # + # @param only_up [Boolean] Only up parameter + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_only_up!(only_up) + validate_parameter!('only_up', only_up, [true, false]) + end + + # Validate protocol parameter + # + # @param protocol [String] Protocol ('tcp' or 'udp') + # @return [true] If valid + # @raise [ValidationError] If invalid + def self.validate_protocol!(protocol) + validate_parameter!('Protocol', protocol.to_s.downcase, %w[tcp udp], allow_nil: true) + end + end + end +end diff --git a/lib/msf/core/mcp/security/rate_limiter.rb b/lib/msf/core/mcp/security/rate_limiter.rb new file mode 100644 index 0000000000000..37dc4801cfc40 --- /dev/null +++ b/lib/msf/core/mcp/security/rate_limiter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Msf::MCP + module Security + class RateLimiter + #attr_reader :requests_per_minute, :burst_size + + # Initialize rate limiter with token bucket algorithm + # + # @param requests_per_minute [Integer] Maximum requests per minute + # @param burst_size [Integer] Maximum burst size (default: same as requests_per_minute) + def initialize(requests_per_minute: 60, burst_size: nil) + @requests_per_minute = requests_per_minute + @burst_size = burst_size || requests_per_minute + @tokens = @burst_size.to_f + @last_refill = Time.now + @mutex = Mutex.new + end + + # Check if request is allowed, consume token if yes + # + # @param tool_name [String, nil] Tool name (for logging/tracking) + # @return [Integer] Number of tokens,if request allowed + # @raise [RateLimitExceededError] If rate limit exceeded + def check_rate_limit!(tool_name = nil) + @mutex.synchronize do + refill! + + if @tokens >= 1.0 + @tokens -= 1.0 + else + # Calculate retry_after in seconds + tokens_per_second = @requests_per_minute / 60.0 + retry_after = ((1.0 - @tokens) / tokens_per_second).ceil + + raise RateLimitExceededError.new(retry_after) + end + end + end + + private + + # Refill tokens based on elapsed time + def refill! + now = Time.now + elapsed = now - @last_refill + + # Calculate tokens to add based on elapsed time + tokens_per_second = @requests_per_minute / 60.0 + tokens_to_add = elapsed * tokens_per_second + + # Add tokens but cap at burst_size + @tokens = [@tokens + tokens_to_add, @burst_size.to_f].min + @last_refill = now + end + end + end +end diff --git a/lib/msf/core/mcp/server.rb b/lib/msf/core/mcp/server.rb new file mode 100644 index 0000000000000..6d80a413fce68 --- /dev/null +++ b/lib/msf/core/mcp/server.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +module Msf::MCP + ## + # MCP Server Wrapper for Metasploit Framework + # + # This class initializes and manages the MCP server with all registered tools. + # It provides a clean interface for starting/stopping the server and integrates + # with the Metasploit client and security layers. + # + # The Server expects fully configured and authenticated dependencies to be + # provided during initialization. It does not handle configuration loading + # or client authentication - those are responsibilities of the calling code. + # + class Server + + ## + # Initialize the MCP server with required dependencies + # + # @param msf_client [Metasploit::Client] Configured and authenticated Metasploit client + # @param rate_limiter [Security::RateLimiter] Configured rate limiter + # + def initialize(msf_client:, rate_limiter:) + @msf_client = msf_client + + # Create server context (passed to all tool calls) + # Tools only need msf_client and rate_limiter + @server_context = { + msf_client: @msf_client, + rate_limiter: rate_limiter + } + + # Create MCP configuration with request lifecycle callbacks + mcp_config = ::MCP::Configuration.new + mcp_config.around_request = create_around_request + mcp_config.exception_reporter = create_exception_reporter + + # Initialize MCP server with all tools + @mcp_server = ::MCP::Server.new( + name: 'msfmcp', + version: Msf::MCP::Application::VERSION, + tools: [ + Tools::SearchModules, + Tools::ModuleInfo, + Tools::HostInfo, + Tools::ServiceInfo, + Tools::VulnerabilityInfo, + Tools::NoteInfo, + Tools::CredentialInfo, + Tools::LootInfo + ], + server_context: @server_context, + configuration: mcp_config + ) + end + + ## + # Start the MCP server with specified transport + # + # @param transport [Symbol] Transport type (:stdio or :http) + # @param host [String] Host address for HTTP transport (default: 'localhost') + # @param port [Integer] Port number for HTTP transport (default: 3000) + # + # @return [MCP::Server] The MCP server instance (for testing purposes) + # @raise [ArgumentError] If an unknown transport is specified + # + def start(transport: :stdio, host: 'localhost', port: 3000) + case transport + when :stdio + start_stdio + when :http + start_http(host, port) + else + raise ArgumentError, "Unknown transport: #{transport}. Use :stdio or :http" + end + end + + ## + # Shutdown the MCP server and cleanup resources + # + def shutdown + @msf_client&.shutdown + @mcp_server = nil + end + + private + + ## + # Start stdio transport (for CLI usage) + # + # @return [MCP::Server] The MCP server instance (for testing purposes) + # + def start_stdio + transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server) + transport.open + @mcp_server + end + + ## + # Start HTTP transport (for web/network usage) + # + # The transport implements the Rack app interface (#call), so it is mounted + # directly. MCP-aware request/response logging is handled by the + # Middleware::RequestLogger middleware. + # + # @param host [String] Host address to bind to + # @param port [Integer] Port to listen on + # + # @return [MCP::Server] The MCP server instance (for testing purposes) + # + def start_http(host, port) + require 'rack' + require 'rack/handler/puma' + + transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server) + + # Build the Rack application with logging middleware. + # The transport itself is a Rack app (implements #call). + rack_app = Rack::Builder.new do + use Msf::MCP::Middleware::RequestLogger + run transport + end + + # Start Puma server using the handler appropriate for the Rack version. + # Rackup::Handler is available with rackup >= 2.x / Rack 3+; + # Rack::Handler is used with Rack < 3 and rackup 1.x. + puma_handler = if defined?(Rackup::Handler) + Rackup::Handler::Puma + else + Rack::Handler::Puma + end + puma_handler.run( + rack_app, + Port: port, + Host: host, + Silent: true + ) + + @mcp_server + end + + ## + # Create around_request callback for MCP SDK + # + # This callback wraps every JSON-RPC request handler, providing access to + # both the instrumentation data and the response result. It replaces the + # deprecated +instrumentation_callback+ which only fires after completion + # and does not expose the result. + # + # The +data+ hash is populated by the SDK with: + # - :method — the JSON-RPC method name (e.g. "tools/call", "tools/list") + # - :tool_name, :prompt_name, :resource_uri — specific handler identifiers + # - :tool_arguments — arguments passed to a tool call + # - :client — client info hash (name, version) + # - :error — error type symbol (e.g. :tool_not_found, :internal_error) + # - :duration — added in the ensure block after this callback returns + # + # @return [Proc] Callback that wraps request execution and logs via Rex + # + def create_around_request + ->(data, &request_handler) do + result = request_handler.call + + # Build message based on the type of request + message = if data[:error] + "MCP Error: #{data[:error]}" + elsif data[:tool_name] + "Tool call: #{data[:tool_name]}" + elsif data[:prompt_name] + "Prompt call: #{data[:prompt_name]}" + elsif data[:resource_uri] + "Resource call: #{data[:resource_uri]}" + elsif data[:method] + "Method call: #{data[:method]}" + else + "MCP request" + end + + context = data.dup + if result + message = "#{message} (ERROR)" if result[:isError] + context[:result] = result + end + + if data[:error] || result&.fetch(:isError, nil) + elog({ message: message, context: context }, LOG_SOURCE, LOG_ERROR) + else + ilog({ message: message, context: context }, LOG_SOURCE, LOG_INFO) + end + + result + end + end + + ## + # Create exception reporter callback for MCP SDK + # + # This callback is invoked for any server exception during request processing, + # which are not tool execution errors. + # It receives: + # - exception: The Ruby exception object + # - context: Hash with :request (JSON string) or :notification (method name string) + # + # @return [Proc] Callback that logs exceptions via Rex + # + def create_exception_reporter + ->(exception, context) do + return unless exception || context + + # Determine the context type and parse data + error_context = {} + + if context&.fetch(:request, nil) + error_context[:type] = 'request' + request = nil + begin + request = JSON.parse(context[:request]) + rescue JSON::ParserError + # Not valid JSON, log raw data + error_context[:raw_data] = context[:request].inspect + else + error_context[:method] = request['method'] if request['method'] + error_context[:params] = request['params'] if request['params'] + end + elsif context&.fetch(:notification, nil) + error_context[:type] = 'notification' + # context[:notification] is the notification method name (string) + error_context[:method] = context[:notification] + else + error_context[:type] = 'unknown' + error_context[:raw_data] = context.inspect + end + + elog({ + message: "Error during #{error_context[:type]} processing#{error_context[:method] ? " (#{error_context[:method]})" : ''}", + exception: exception, + context: error_context + }, LOG_SOURCE, LOG_ERROR) + end + end + end +end diff --git a/lib/msf/core/mcp/tools/credential_info.rb b/lib/msf/core/mcp/tools/credential_info.rb new file mode 100644 index 0000000000000..9d003bab7ec64 --- /dev/null +++ b/lib/msf/core/mcp/tools/credential_info.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Credentials + # + # Retrieves credential information from the Metasploit database including + # usernames, password hashes, and authentication data. + # + class CredentialInfo < ::MCP::Tool + tool_name 'msf_credential_info' + description 'Query Metasploit database for discovered credentials. '\ + 'Returns credential information including usernames and password data.' + + input_schema( + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + }, + required: [:workspace] + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + host: { type: 'string' }, + port: { type: 'integer' }, + protocol: { type: 'string' }, + service_name: { type: 'string' }, + user: { type: 'string' }, + secret: { type: 'string' }, + type: { type: 'string' }, + updated_at: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute credential query with secure memory handling + # + # @param workspace [String] Workspace name (default: 'default') + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with credential information + # + def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('credential_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + raw_creds = msf_client.db_creds(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_creds(raw_creds) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/host_info.rb b/lib/msf/core/mcp/tools/host_info.rb new file mode 100644 index 0000000000000..cead5a9af0739 --- /dev/null +++ b/lib/msf/core/mcp/tools/host_info.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Hosts + # + # Retrieves host information from the Metasploit database including + # IP addresses, operating systems, and discovery metadata. + # + class HostInfo < ::MCP::Tool + tool_name 'msf_host_info' + description 'Query Metasploit database for discovered hosts. '\ + 'Returns host information including IP, OS, MAC address, and metadata.' + + input_schema( + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + addresses: { + type: 'string', + description: 'IP address or CIDR range to filter (e.g., "192.168.1.100" or "192.168.1.0/24")' + }, + only_up: { + type: 'boolean', + description: 'Filter to only return hosts that are up', + default: false + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + } + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + created_at: { type: 'string' }, + address: { type: 'string' }, + mac_address: { type: 'string' }, + hostname: { type: 'string' }, + state: { type: 'string' }, + os_name: { type: 'string' }, + os_flavor: { type: 'string' }, + os_service_pack: { type: 'string' }, + os_language: { type: 'string' }, + updated_at: { type: 'string' }, + purpose: { type: 'string' }, + info: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute host query + # + # @param workspace [String] Workspace name (default: 'default') + # @param addresses [String, nil] IP address or CIDR range to filter + # @param only_up [Boolean] Filter to only return hosts that are up + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with host information + # + def call(workspace: 'default', addresses: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('host_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_only_up!(only_up) + Msf::MCP::Security::InputValidator.validate_ip_address!(addresses) if addresses + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + options[:addresses] = addresses if addresses + options[:only_up] = only_up if only_up + raw_hosts = msf_client.db_hosts(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_hosts(raw_hosts) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/loot_info.rb b/lib/msf/core/mcp/tools/loot_info.rb new file mode 100644 index 0000000000000..62a56e29058f0 --- /dev/null +++ b/lib/msf/core/mcp/tools/loot_info.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Loot + # + # Retrieves loot information from the Metasploit database including + # collected files, data, and artifacts from compromised systems. + # + class LootInfo < ::MCP::Tool + tool_name 'msf_loot_info' + description 'Query Metasploit database for collected loot. '\ + 'Returns loot information including file paths and content types.' + + input_schema( + + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + }, + required: [:workspace] + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + host: { type: 'string' }, + service_name_or_port: { type: 'string' }, + loot_type: { type: 'string' }, + content_type: { type: 'string' }, + name: { type: 'string' }, + info: { type: 'string' }, + data: { type: 'string' }, + created_at: { type: 'string' }, + updated_at: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute loot query + # + # @param workspace [String] Workspace name (default: 'default') + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with loot information + # + def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('loot_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + raw_loot = msf_client.db_loot(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_loot(raw_loot) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/module_info.rb b/lib/msf/core/mcp/tools/module_info.rb new file mode 100644 index 0000000000000..de2bcacf7be6c --- /dev/null +++ b/lib/msf/core/mcp/tools/module_info.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Get Metasploit Module Information + # + # Retrieves detailed information about a specific Metasploit module including + # options, targets, references, and compatibility details. + # + class ModuleInfo < ::MCP::Tool + tool_name 'msf_module_info' + description 'Retrieves detailed information, documentation, and options for a single specific Metasploit module. '\ + 'Returns comprehensive module details including options, targets, payloads, and references.' + + input_schema( + properties: { + type: { + type: 'string', + description: 'Module type (exploit, auxiliary, post, payload, etc.)', + enum: ['exploit', 'auxiliary', 'post', 'payload', 'encoder', 'evasion', 'nop'] + }, + name: { + type: 'string', + description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)', + minLength: 1, + maxLength: 500 + } + }, + required: [:type, :name] + ) + + output_schema( + properties: { + metadata: { + properties: { + query_time: { type: 'number' } + } + }, + data: { + properties: { + # TODO: consider adding `description` fields to these properties + type: { type: 'string' }, + name: { type: 'string' }, + fullname: { type: 'string' }, + rank: { type: 'string' }, + disclosure_date: { type: 'string' }, + description: { type: 'string' }, + license: { type: 'string' }, + filepath: { type: 'string' }, + architectures: { type: 'array', items: { type: 'string', enum: %w[ + x86 x86_64 x64 mips mipsle mipsbe mips64 mips64le ppc ppce500v2 + ppc64 ppc64le cbea cbea64 sparc sparc64 armle armbe aarch64 cmd + php tty java ruby dalvik python nodejs firefox zarch r + riscv32be riscv32le riscv64be riscv64le loongarch64 + ] } }, + platforms: { type: 'array', items: { type: 'string' } }, + authors: { type: 'array', items: { type: 'string' } }, + privileged: { type: 'boolean' }, + has_check_method: { type: 'boolean' }, + default_options: { type: 'object' }, + references: { type: 'array', items: { type: ['string', 'object'] } }, + targets: { type: 'object' }, + default_target: { type: 'integer' }, + stance: { type: 'string' }, + actions: { type: 'object' }, + default_action: { type: 'integer' }, + options: { 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 module info retrieval + # + # @param type [String] Type of module + # @param name [String] Name/path of module + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with module details + # + def call(type:, name:, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('module_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_module_type!(type) + Msf::MCP::Security::InputValidator.validate_module_name!(name) + + # Call Metasploit API + raw_module_info = msf_client.module_info(type, name) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_module_info(raw_module_info) + + # Build metadata + metadata = { + query_time: (Time.now - start_time).round(3) + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: transformed + ) + } + ], + structured_content: { + metadata: metadata, + data: transformed + } + ) + 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 new file mode 100644 index 0000000000000..776288e6dedbc --- /dev/null +++ b/lib/msf/core/mcp/tools/note_info.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Notes + # + # Retrieves notes from the Metasploit database including user annotations, + # scan results, and discovery metadata. + # + class NoteInfo < ::MCP::Tool + tool_name 'msf_note_info' + description 'Query Metasploit database for notes and annotations. '\ + 'Returns notes including host associations and metadata.' + + input_schema( + + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + type: { + type: 'string', + description: 'Note type (e.g. "ssl.certificate", "smb.fingerprint")' + }, + host: { + type: 'string', + description: 'Host IP address to filter (e.g., "192.168.1.100")' + }, + ports: { + type: 'string', + description: 'Port number or range to filter (e.g., "80" or "80-443")' + }, + protocol: { + type: 'string', + description: 'Protocol to filter (tcp or udp)', + enum: ['tcp', 'udp'] + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + }, + required: [:workspace] + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + host: { type: 'string' }, + service_name_or_port: { type: 'string' }, + note_type: { type: 'string' }, + data: { type: 'string' }, + created_at: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute note query + # + # @param workspace [String] Workspace name (default: 'default') + # @param host [String, nil] Host IP address to filter + # @param type [String, nil] Note type to filter + # @param ports [String, nil] Port or port range to filter + # @param protocol [String, nil] Protocol to filter (tcp or udp) + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with note information + # + def call(workspace: 'default', host: nil, type: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('note_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol + Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host + Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + options[:address] = host if host + options[:ntype] = type if type + options[:ports] = ports if ports + options[:proto] = protocol if protocol + raw_notes = msf_client.db_notes(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_notes(raw_notes) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/search_modules.rb b/lib/msf/core/mcp/tools/search_modules.rb new file mode 100644 index 0000000000000..2af666efbb3f0 --- /dev/null +++ b/lib/msf/core/mcp/tools/search_modules.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Search Metasploit Modules + # + # Searches the Metasploit Framework module database using various criteria. + # Supports keyword search, filtering by type, platform, and pagination. + # + class SearchModules < ::MCP::Tool + tool_name 'msf_search_modules' + description 'Search Metasploit modules according to generic search terms or specific criteria. '\ + 'Returns a list of modules matching the search criteria.' + + input_schema( + properties: { + # TODO: improve search criteria by adding the supported key/value pair. + # The API support things like `type:exploit platform:windows cve:CVE-2021-34527` + # Maybe adding specific fields for type, platform, cve, etc. + query: { + type: 'string', + description: 'Search query (keywords, module names, or CVE IDs)', + minLength: 1, + maxLength: 500 + }, + limit: { + type: 'integer', + description: 'Maximum number of results to return', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip (for pagination)', + minimum: 0, + default: 0 + } + }, + required: [:query] + ) + + output_schema( + properties: { + metadata: { + properties: { + query: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + fullname: { type: 'string' }, + type: { type: 'string' }, + name: { type: 'string' }, + rank: { type: 'string' }, + disclosure_date: { 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 module search + # + # @param query [String] Search query + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with search results + # + def call(query:, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('search_modules') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_search_query!(query) + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + + # Call Metasploit API + raw_modules = msf_client.search_modules(query) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_modules(raw_modules) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + query: query, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/service_info.rb b/lib/msf/core/mcp/tools/service_info.rb new file mode 100644 index 0000000000000..053e017de4e58 --- /dev/null +++ b/lib/msf/core/mcp/tools/service_info.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Services + # + # Retrieves service information from the Metasploit database including + # ports, protocols, and service banners. + # + class ServiceInfo < ::MCP::Tool + tool_name 'msf_service_info' + description 'Query Metasploit database for discovered services. '\ + 'Returns service information including ports, protocols, and banners.' + + input_schema( + + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + names: { + type: 'string', + description: 'Comma-separated service names to filter (e.g., "http,https,ssh")' + }, + host: { + type: 'string', + description: 'Host IP address (e.g., "192.168.1.100")' + }, + ports: { + type: 'string', + description: 'Port number or range to filter (e.g., "80" or "80-443")' + }, + protocol: { + type: 'string', + description: 'Protocol to filter (tcp or udp)', + enum: ['tcp', 'udp'] + }, + only_up: { + type: 'boolean', + description: 'Filter to only return services on hosts that are up', + default: false + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + }, + required: [:workspace] + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + host_address: { type: 'string' }, + created_at: { type: 'string' }, + updated_at: { type: 'string' }, + port: { type: 'integer' }, + protocol: { type: 'string' }, + state: { type: 'string' }, + name: { type: 'string' }, + info: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute service query + # + # @param workspace [String] Workspace name (default: 'default') + # @param names [String, nil] Comma-separated service names to filter + # @param ports [String, nil] Port number or range to filter + # @param host [String, nil] Host IP address + # @param protocol [String, nil] Protocol to filter (tcp or udp) + # @param only_up [Boolean] Filter to only return services on hosts that are up + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with service information + # + def call(workspace: 'default', names: nil, ports: nil, host: nil, protocol: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('service_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + Msf::MCP::Security::InputValidator.validate_only_up!(only_up) + Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol + Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host + Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + options[:only_up] = only_up if only_up + options[:proto] = protocol if protocol + # The API is misleading, it only supports a single address filter, not multiple. + options[:addresses] = host if host + options[:ports] = ports if ports + options[:names] = names if names + raw_services = msf_client.db_services(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_services(raw_services) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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/tool_helper.rb b/lib/msf/core/mcp/tools/tool_helper.rb new file mode 100644 index 0000000000000..56cc84b788120 --- /dev/null +++ b/lib/msf/core/mcp/tools/tool_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # Shared helper methods for MCP tools. + # + # Provides a standard way to build error responses that comply with the + # MCP protocol, returning a normal result with `isError: true` instead + # of raising exceptions that the MCP server would wrap as internal errors. + # + module ToolHelper + ## + # Build a standard MCP error response. + # + # @param message [String] Human-readable error message + # @return [::MCP::Tool::Response] Response with isError flag set + # + def tool_error_response(message) + ::MCP::Tool::Response.new( + [{ type: 'text', text: message }], + error: true + ) + end + end + end +end diff --git a/lib/msf/core/mcp/tools/vulnerability_info.rb b/lib/msf/core/mcp/tools/vulnerability_info.rb new file mode 100644 index 0000000000000..8a548139366fa --- /dev/null +++ b/lib/msf/core/mcp/tools/vulnerability_info.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module Msf::MCP + module Tools + ## + # MCP Tool: Query Metasploit Database Vulnerabilities + # + # Retrieves vulnerability information from the Metasploit database including + # CVE IDs, affected hosts, and vulnerability details. + # + class VulnerabilityInfo < ::MCP::Tool + tool_name 'msf_vulnerability_info' + description 'Query Metasploit database for discovered vulnerabilities. '\ + 'Returns vulnerability information including CVE IDs and affected hosts.' + + input_schema( + + properties: { + workspace: { + type: 'string', + description: 'Workspace name (default: "default")', + default: 'default' + }, + names: { + type: 'array', + items: { + type: 'string', + description: 'Exploit that reported the vulnerability. It needs to be the exact module name, case sensitive, not the path (e.g. "SSH User Code Execution" or "WebEx Local Service Permissions Exploit").' + } + }, + host: { + type: 'string', + description: 'Host IP address to filter (e.g., "192.168.1.100")' + }, + ports: { + type: 'string', + description: 'Port number or range to filter (e.g., "80" or "80-443")' + }, + protocol: { + type: 'string', + description: 'Protocol to filter (tcp or udp)', + enum: ['tcp', 'udp'] + }, + limit: { + type: 'integer', + description: 'Maximum number of results', + minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN, + maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX, + default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT + }, + offset: { + type: 'integer', + description: 'Number of results to skip', + minimum: 0, + default: 0 + } + }, + required: [:workspace] + ) + + output_schema( + properties: { + metadata: { + properties: { + workspace: { type: 'string' }, + query_time: { type: 'number' }, + total_items: { type: 'integer' }, + returned_items: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + data: { + type: 'array', + items: { + properties: { + host: { type: 'string' }, + port: { type: 'integer' }, + protocol: { type: 'string' }, + name: { type: 'string' }, + references: { type: 'array', items: { type: 'string' } }, + created_at: { type: 'string' } + } + } + } + }, + required: [:metadata, :data] + ) + + annotations( + read_only_hint: true, + idempotent_hint: true, + destructive_hint: false + ) + + meta({ source: 'metasploit_database' }) + + class << self + include ToolHelper + + ## + # Execute vulnerability query + # + # @param workspace [String] Workspace name (default: 'default') + # @param host [String, nil] Host IP address to filter + # @param names [Array, nil] Exploit names to filter + # @param ports [String, nil] Port or port range to filter + # @param protocol [String, nil] Protocol to filter (tcp or udp) + # @param limit [Integer] Maximum results (default: 100) + # @param offset [Integer] Results offset (default: 0) + # @param server_context [Hash] Server context with msf_client, rate_limiter, config + # @return [MCP::Tool::Response] Structured response with vulnerability information + # + def call(workspace: 'default', host: nil, names: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:) + start_time = Time.now + + # Extract dependencies from server context + msf_client = server_context[:msf_client] + rate_limiter = server_context[:rate_limiter] + + # Check rate limit + rate_limiter.check_rate_limit!('vulnerability_info') + + # Validate inputs + Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset) + Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol + Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host + Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports + + # Call Metasploit API + # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided. + # The default value is sent anyway for clarity. + options = { workspace: workspace } + options[:address] = host if host + options[:names] = names.join(',') if names && names.is_a?(Array) && names.any? + options[:ports] = ports if ports + options[:proto] = protocol if protocol + raw_vulns = msf_client.db_vulns(options) + + # Transform response + transformed = Metasploit::ResponseTransformer.transform_vulns(raw_vulns) + + # Apply pagination + # + # Note that to get the total number of entries, we gather the entire data set and apply pagination here + # instead of sending the limit and offset to the API call to be processed by MSF. + # This is needed to provide accurate total_items count in the metadata. + total_items = transformed.size + paginated_data = transformed[offset, limit] || [] + + # Build metadata + metadata = { + workspace: workspace, + query_time: (Time.now - start_time).round(3), + total_items: total_items, + returned_items: paginated_data.size, + limit: limit, + offset: offset + } + + # Return MCP response + ::MCP::Tool::Response.new( + [ + { + type: 'text', + text: JSON.generate( + metadata: metadata, + data: paginated_data + ) + } + ], + structured_content: { + metadata: metadata, + data: paginated_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_autoload.rb b/lib/msf_autoload.rb index d69ac6cf93ba3..3ac7924eb8833 100644 --- a/lib/msf_autoload.rb +++ b/lib/msf_autoload.rb @@ -65,7 +65,9 @@ def ignore_list "#{__dir__}/rex/post.rb", "#{__dir__}/rex/proto/ssh/hrr_rb_ssh.rb", "#{__dir__}/rex/proto/ssh/connection.rb", - "#{__dir__}/rex/proto/kerberos/pac/krb5_pac.rb" + "#{__dir__}/rex/proto/kerberos/pac/krb5_pac.rb", + "#{__dir__}/msf/core/mcp.rb", + "#{__dir__}/msf/core/mcp/" ] end diff --git a/lib/rex/logging.rb b/lib/rex/logging.rb index eb2fb80bacb06..59a940be8b918 100644 --- a/lib/rex/logging.rb +++ b/lib/rex/logging.rb @@ -1,5 +1,6 @@ # -*- coding: binary -*- -module Rex::Logging +module Rex +module Logging # @@ -65,3 +66,4 @@ module Rex::Logging require 'rex/logging/log_dispatcher' end +end diff --git a/metasploit-framework.gemspec b/metasploit-framework.gemspec index cdc8ab16286b8..219b877157f12 100644 --- a/metasploit-framework.gemspec +++ b/metasploit-framework.gemspec @@ -271,6 +271,8 @@ Gem::Specification.new do |spec| # Needed for caching validation spec.add_runtime_dependency 'parallel' + spec.add_runtime_dependency 'mcp', '0.13.0' + # Standard libraries: https://www.ruby-lang.org/en/news/2023/12/25/ruby-3-3-0-released/ %w[ abbrev diff --git a/msfmcpd b/msfmcpd new file mode 100755 index 0000000000000..46acc0a0e522f --- /dev/null +++ b/msfmcpd @@ -0,0 +1,53 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# MSF MCP Server - Model Context Protocol server for Metasploit Framework +# +# This executable provides a MCP server that exposes Metasploit +# functionality to AI agents and LLM applications. +# +# Usage: +# msfmcpd [options] +# +# Options: +# --config PATH Path to configuration file +# --enable-logging Enable file logging with sanitization +# --log-file PATH Log file path (overrides config file) +# --user USER MSF API username (for MessagePack auth) +# --password PASS MSF API password (for MessagePack auth) +# --no-auto-start-rpc Disable automatic RPC server startup +# -h, --help Show this help message +# -v, --version Show version information +# +# Settings can be overridden by environment variables: +# MSF_API_TYPE Metasploit RPC API connection type ('messagepack' or 'json-rpc') +# MSF_API_HOST Metasploit RPC API host +# MSF_API_PORT Metasploit RPC API port +# MSF_API_SSL Use SSL for Metasploit RPC API +# MSF_API_ENDPOINT Metasploit RPC API endpoint +# MSF_API_USER Metaspoit RPC API username (for MessagePack auth) +# MSF_API_PASSWORD Metaspoit RPC API password (for MessagePack auth) +# MSF_API_TOKEN Metaspoit RPC API token (for JSON-RPC auth) +# MSF_AUTO_START_RPC Auto-start Metasploit RPC server ('true' or 'false') +# MSF_MCP_TRANSPORT MCP server transport type ('stdio' or 'http') +# MSF_MCP_HOST MCP server host +# MSF_MCP_PORT MCP server port +# +# Examples: +# # Start with default configuration (default: MessagePack connection type, so credentials must be provided) +# msfmcpd --user msf_user --password msf_pass +# +# # Start with custom configuration and logging +# msfmcpd --config ./config/mcp_config.yaml --enable-logging +# +# # Use environment variables to override the configuration +# MSF_API_HOST=192.168.33.44 msfmcpd --config ./config/mcp_config.yaml + +require 'bundler/setup' + +lib_path = File.join(File.dirname(__dir__), 'lib') +$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) + +require 'msf/core/mcp' + +Msf::MCP::Application.new(ARGV).run diff --git a/msfrpcd b/msfrpcd index 4e83804d27797..52b5c8eddc968 100755 --- a/msfrpcd +++ b/msfrpcd @@ -203,6 +203,11 @@ if $PROGRAM_NAME == __FILE__ end } + # Allow credentials via environment variables (used by msfmcpd auto-start + # to avoid passing secrets on the command line). + opts['User'] ||= ENV['MSF_RPC_USER'] if ENV['MSF_RPC_USER'] + opts['Pass'] ||= ENV['MSF_RPC_PASS'] if ENV['MSF_RPC_PASS'] + $0 = "msfrpcd" begin diff --git a/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml b/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml new file mode 100644 index 0000000000000..314e79bffac13 --- /dev/null +++ b/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml @@ -0,0 +1,24 @@ +# Valid JSON-RPC configuration for testing +msf_api: + type: json-rpc + host: localhost + port: 8081 + endpoint: /api/v1/json-rpc + token: test_bearer_token_12345 + +# MCP server configuration +mcp: + transport: stdio + host: localhost + port: 3000 + +# Rate limiting +rate_limit: + enabled: true + requests_per_minute: 60 + burst_size: 10 + +# Logging +logging: + enabled: false + level: INFO diff --git a/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml b/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml new file mode 100644 index 0000000000000..56cba28e9442c --- /dev/null +++ b/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml @@ -0,0 +1,25 @@ +# Valid MessagePack configuration for testing +msf_api: + type: messagepack + host: localhost + port: 55553 + endpoint: /api/ + user: test_user + password: test_password + +# MCP server configuration +mcp: + transport: stdio + host: localhost + port: 3000 + +# Rate limiting +rate_limit: + enabled: true + requests_per_minute: 60 + burst_size: 10 + +# Logging +logging: + enabled: false + level: INFO diff --git a/spec/integration/msfmcpd/config_loading_spec.rb b/spec/integration/msfmcpd/config_loading_spec.rb new file mode 100644 index 0000000000000..3be855a9abbc1 --- /dev/null +++ b/spec/integration/msfmcpd/config_loading_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' +require 'tempfile' + +RSpec.describe 'Configuration Loading and Validation Integration' do + let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') } + let(:valid_messagepack_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') } + let(:valid_jsonrpc_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_jsonrpc.yaml') } + + before do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after do + WebMock.allow_net_connect! + end + + describe 'Application Initialization with Configuration' do + let(:output) { StringIO.new } + let(:argv) { ['--config', valid_messagepack_path] } + + it 'successfully initializes all components with valid MessagePack config' do + app = Msf::MCP::Application.new(argv, output: output) + + # Stub Metasploit authentication + stub_request(:post, 'https://localhost:55553/api/') + .to_return( + status: 200, + body: MessagePack.pack({ + 'result' => 'success', + 'token' => 'fake_token_123' + }) + ) + + # Execute initialization steps (before start) + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + app.send(:initialize_rate_limiter) + app.send(:initialize_metasploit_client) + + # Verify components are initialized with config values + expect(app.config[:msf_api][:type]).to eq('messagepack') + expect(app.config[:msf_api][:host]).to eq('localhost') + expect(app.config[:msf_api][:port]).to eq(55553) + + # Verify rate limiter is configured from config + expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter) + expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60) + expect(app.rate_limiter.instance_variable_get(:@burst_size)).to eq(10) + + # Verify Metasploit client is created with config values + expect(app.msf_client).to be_a(Msf::MCP::Metasploit::Client) + end + + it 'successfully initializes all components with valid JSON-RPC config' do + argv = ['--config', valid_jsonrpc_path] + app = Msf::MCP::Application.new(argv, output: output) + + # Execute initialization steps + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + app.send(:initialize_rate_limiter) + app.send(:initialize_metasploit_client) + + # Verify config is loaded correctly + expect(app.config[:msf_api][:type]).to eq('json-rpc') + expect(app.config[:msf_api][:port]).to eq(8081) + expect(app.config[:msf_api][:token]).to eq('test_bearer_token_12345') + + # Verify components are initialized + expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter) + expect(app.msf_client).to be_a(Msf::MCP::Metasploit::Client) + end + + it 'applies defaults and starts successfully with minimal config' do + minimal_config = { + msf_api: { + type: 'messagepack', + user: 'msf', + password: 'pass' + } + } + + config_file = Tempfile.new(['minimal_config', '.yaml']) + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(minimal_config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + # Stub authentication + stub_request(:post, 'https://localhost:55553/api/') + .to_return( + status: 200, + body: MessagePack.pack({ + 'result' => 'success', + 'token' => 'token' + }) + ) + + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + + # Verify defaults were applied + expect(app.config[:msf_api][:host]).to eq('localhost') + expect(app.config[:msf_api][:port]).to eq(55553) + expect(app.config[:rate_limit][:requests_per_minute]).to eq(60) + expect(app.config[:logging][:level]).to eq('INFO') + + # Verify can proceed with initialization + expect { + app.send(:initialize_rate_limiter) + app.send(:initialize_metasploit_client) + }.not_to raise_error + + config_file.close + config_file.unlink + end + + it 'fails gracefully when config has invalid port' do + invalid_config = YAML.safe_load_file(valid_messagepack_path) + invalid_config['msf_api']['port'] = 999999 + + config_file = Tempfile.new(['invalid_port', '.yaml']) + config_file.write(YAML.dump(invalid_config)) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + expect { + app.run + }.to raise_error(SystemExit) + + expect(output.string).to include('Configuration validation failed') + expect(output.string).to include('port must be between') + + config_file.close + config_file.unlink + end + + it 'fails gracefully when config is missing required authentication on remote host' do + invalid_config = { + msf_api: { + type: 'messagepack', + host: '192.168.1.100', + port: 55553, + auto_start_rpc: false + } + } + + config_file = Tempfile.new(['missing_auth', '.yaml']) + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(invalid_config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + expect { + app.run + }.to raise_error(SystemExit) + + expect(output.string).to include('Configuration validation failed') + expect(output.string).to match(/user|password/) + + config_file.close + config_file.unlink + end + + it 'prevents application startup with invalid API type' do + invalid_config = YAML.safe_load_file(valid_messagepack_path) + invalid_config['msf_api']['type'] = 'soap' + + config_file = Tempfile.new(['invalid_type', '.yaml']) + config_file.write(YAML.dump(invalid_config)) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + expect { + app.run + }.to raise_error(SystemExit) + + expect(output.string).to include('Configuration validation failed') + expect(output.string).to include('msf_api.type') + + config_file.close + config_file.unlink + end + end + + describe 'Environment Variable Override Integration' do + let(:output) { StringIO.new } + + after do + # Clean up ENV + %w[MSF_API_HOST MSF_API_PORT MSF_API_TYPE MSF_API_USER MSF_API_PASSWORD MSF_API_TOKEN].each { |key| ENV.delete(key) } + end + + it 'ENV override changes which Metasploit host client connects to' do + ENV['MSF_API_HOST'] = '192.168.1.100' + + app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output) + + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + app.send(:initialize_metasploit_client) + + # Verify client was created with ENV-overridden host + expect(app.config[:msf_api][:host]).to eq('192.168.1.100') + + # Verify that making an API call would use the overridden host + stub_request(:post, 'https://192.168.1.100:55553/api/') + .to_return( + status: 200, + body: MessagePack.pack({ + 'result' => 'success', + 'token' => 'token_123' + }) + ) + + expect { + app.msf_client.authenticate('test_user', 'test_password') + }.not_to raise_error + + # Verify the request was made to the correct host + expect(WebMock).to have_requested(:post, 'https://192.168.1.100:55553/api/').once + end + + it 'ENV override changes authentication credentials used' do + ENV['MSF_API_USER'] = 'env_override_user' + ENV['MSF_API_PASSWORD'] = 'env_override_pass' + + app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output) + + # Stub authentication - accept any body since MessagePack matching is problematic + stub_request(:post, 'https://localhost:55553/api/') + .to_return( + status: 200, + body: MessagePack.pack({ + 'result' => 'success', + 'token' => 'env_token' + }) + ) + + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + app.send(:initialize_metasploit_client) + + # Verify ENV vars override config file + expect(app.config[:msf_api][:user]).to eq('env_override_user') + expect(app.config[:msf_api][:password]).to eq('env_override_pass') + + # Verify authentication uses ENV credentials + app.send(:authenticate_metasploit) + + expect(WebMock).to have_requested(:post, 'https://localhost:55553/api/').once + end + + it 'ENV override changes API type from MessagePack to JSON-RPC' do + ENV['MSF_API_TYPE'] = 'json-rpc' + ENV['MSF_API_PORT'] = '8081' + ENV['MSF_API_TOKEN'] = 'env_token_123' # JSON-RPC requires token + + app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output) + + app.send(:parse_arguments) + app.send(:load_configuration) + app.send(:validate_configuration) + app.send(:initialize_metasploit_client) + + # Verify type was overridden + expect(app.config[:msf_api][:type]).to eq('json-rpc') + expect(app.config[:msf_api][:port]).to eq(8081) + expect(app.config[:msf_api][:token]).to eq('env_token_123') + + # Verify client is JSON-RPC client (not MessagePack) + underlying_client = app.msf_client.instance_variable_get(:@client) + expect(underlying_client).to be_a(Msf::MCP::Metasploit::JsonRpcClient) + end + end + + describe 'CLI Flag Override Integration' do + let(:output) { StringIO.new } + + it 'CLI --user and --password flags override config file authentication' do + app = Msf::MCP::Application.new( + ['--config', valid_messagepack_path, '--user', 'cli_user', '--password', 'cli_pass'], + output: output + ) + + # Stub authentication - accept any body + stub_request(:post, 'https://localhost:55553/api/') + .to_return( + status: 200, + body: MessagePack.pack({ + 'result' => 'success', + 'token' => 'cli_token' + }) + ) + + app.send(:parse_arguments) + app.send(:load_configuration) + + # Verify CLI args override config file + expect(app.config[:msf_api][:user]).to eq('cli_user') + expect(app.config[:msf_api][:password]).to eq('cli_pass') + + app.send(:validate_configuration) + app.send(:initialize_metasploit_client) + app.send(:authenticate_metasploit) + + # Verify authentication was called + expect(WebMock).to have_requested(:post, 'https://localhost:55553/api/').once + end + end +end diff --git a/spec/integration/msfmcpd/error_handling_spec.rb b/spec/integration/msfmcpd/error_handling_spec.rb new file mode 100644 index 0000000000000..21a7dc95a00ce --- /dev/null +++ b/spec/integration/msfmcpd/error_handling_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'Error Handling Integration' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + + describe 'MessagePack Error Handling' do + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:client) do + Msf::MCP::Metasploit::Client.new( + api_type: 'messagepack', + host: host, + port: port, + endpoint: endpoint, + ssl: true + ) + end + + before do + # Mock successful authentication to get the token + stub_request(:post, api_url) + .with(body: ['auth.login', 'user', 'pass'].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + client.authenticate('user', 'pass') + end + + describe 'Network Connection Failures' do + it 'converts Errno::ECONNREFUSED to ConnectionError' do + # Mock a connection refused error + stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_raise(Errno::ECONNREFUSED) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Cannot connect to Metasploit RPC/) + end + + it 'converts SocketError to ConnectionError' do + stub_request(:post, api_url) + .to_raise(SocketError.new('getaddrinfo: Name or service not known')) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Network error/) + end + + it 'converts Timeout::Error to ConnectionError' do + # Timeout on search + stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_raise(Timeout::Error.new('execution expired')) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Request timeout/) + end + + it 'converts EOFError to ConnectionError' do + stub_request(:post, api_url) + .to_raise(EOFError.new('end of file reached')) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Empty response/) + end + end + + describe 'HTTP Status Code Handling' do + it 'converts HTTP 401 to AuthenticationError' do + # Reauthenticate with invalid creds + stub_request(:post, api_url) + .to_return( + status: 401, + body: { 'error_message' => 'Invalid credentials' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + expect { client.authenticate('invalid', 'invalid') }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Invalid credentials/) + end + + it 'converts HTTP 500 to APIError' do + # Server error on API call + stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_return( + status: 500, + body: { 'error_message' => 'Internal server error' }.to_msgpack + ) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::APIError, /Internal server error/) + end + + it 'converts unexpected HTTP status to ConnectionError' do + stub_request(:post, api_url) + .to_return(status: 503, body: 'Service Unavailable') + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /HTTP 503/) + end + end + + describe 'Tool Error Handling Integration' do + let(:rate_limiter) { Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60) } + let(:server_context) do + { + msf_client: client, + rate_limiter: rate_limiter, + config: {} + } + end + + it 'converts Metasploit errors to MCP error responses end-to-end' do + # Test that errors propagate correctly through the entire stack: + # HTTP error → Metasploit exception → MCP error response (isError: true) + stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_return( + status: 401, + body: { 'error_message' => 'Token invalid' }.to_msgpack + ) + + result = Msf::MCP::Tools::SearchModules.call(query: 'smb', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + end + end + + describe 'JSON-RPC Error Handling' do + let(:jsonrpc_url) { "https://#{host}:#{port}/api/v1/json-rpc" } + let(:client) do + Msf::MCP::Metasploit::Client.new( + api_type: 'json-rpc', + host: host, + port: port, + endpoint: '/api/v1/json-rpc', + token: 'bearer_token', + ssl: true + ) + end + + it 'handles JSON-RPC error responses' do + stub_request(:post, jsonrpc_url) + .to_return( + status: 200, + body: { + jsonrpc: '2.0', + error: { code: -32601, message: 'Method not found' }, + id: 1 + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::APIError, /Method not found/) + end + + it 'handles network errors with JSON-RPC client' do + stub_request(:post, jsonrpc_url) + .to_raise(Errno::ECONNREFUSED) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError) + end + + it 'raises error with invalid bearer token' do + stub_request(:post, jsonrpc_url) + .to_return( + status: 401, + body: { 'error' => 'Unauthorized' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Invalid authentication token/) + end + end +end diff --git a/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb b/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb new file mode 100644 index 0000000000000..b826735a56c31 --- /dev/null +++ b/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'JSON-RPC Authentication Flow Integration' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 8081 } + let(:endpoint) { '/api/v1/json-rpc' } + let(:token) { 'test_bearer_token_12345' } + let(:jsonrpc_url) { "https://#{host}:#{port}#{endpoint}" } + + describe 'Bearer Token Authentication' do + it 'uses bearer token in HTTP headers' do + # Stub HTTP endpoint and verify Authorization header + stub = stub_request(:post, jsonrpc_url) + .with( + headers: { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json' + } + ) + .to_return( + status: 200, + body: { jsonrpc: '2.0', result: { modules: [] }, id: 1 }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + client = Msf::MCP::Metasploit::JsonRpcClient.new( + host: host, + port: port, + endpoint: endpoint, + token: token + ) + + client.call_api('module.search', ['smb']) + + # Verify the HTTP request was made with correct Authorization header + expect(stub).to have_been_requested.once + end + + it 'follows stateless request pattern (no session management)' do + client = Msf::MCP::Metasploit::JsonRpcClient.new( + host: host, + port: port, + endpoint: endpoint, + token: token + ) + + # No session storage should exist (only token) + expect(client.instance_variable_defined?(:@session_id)).to eq(false) + expect(client.instance_variable_defined?(:@session_token)).to eq(false) + + # Has token stored + expect(client.instance_variable_get(:@token)).to eq(token) + + # No session state (only token which is stateless) + expect(client.instance_variables.grep(/@session/)).to be_empty + end + end +end diff --git a/spec/integration/msfmcpd/logging_pipeline_spec.rb b/spec/integration/msfmcpd/logging_pipeline_spec.rb new file mode 100644 index 0000000000000..0d7bf1fe3f46e --- /dev/null +++ b/spec/integration/msfmcpd/logging_pipeline_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'stringio' +require 'tempfile' +require 'json' + +RSpec.describe 'Logging Pipeline Integration' do + let(:output) { StringIO.new } + let(:log_file) { Tempfile.new(['logging_integration', '.log']).tap(&:close).path } + let(:log_src) { Msf::MCP::LOG_SOURCE } + let(:lvl_info) { Msf::MCP::LOG_INFO } + let(:lvl_warn) { Msf::MCP::LOG_WARN } + let(:lvl_error) { Msf::MCP::LOG_ERROR } + + after do + deregister_log_source(log_src) if log_source_registered?(log_src) + File.delete(log_file) if File.exist?(log_file) + end + + describe 'initialize_logger with sanitize enabled' do + it 'produces JSON log entries with sensitive data redacted' do + app = Msf::MCP::Application.new([], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { + logging: { + enabled: true, + level: 'INFO', + log_file: log_file, + sanitize: true + } + }) + app.send(:initialize_logger) + + ilog({ message: 'Connection established', context: { password: 's3cret', host: 'localhost' } }, log_src, lvl_info) + + content = File.read(log_file) + expect(content).not_to be_empty + + entry = JSON.parse(content.strip.split("\n").last) + + expect(entry['timestamp']).not_to be_nil + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('Connection established') + + expect(entry['context']['password']).to eq('[REDACTED]') + expect(entry['context']['host']).to eq('localhost') + + expect(content).not_to include('s3cret') + end + end + + describe 'initialize_logger with sanitize disabled' do + it 'produces JSON log entries without redaction' do + app = Msf::MCP::Application.new([], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { + logging: { + enabled: true, + level: 'INFO', + log_file: log_file, + sanitize: false + } + }) + app.send(:initialize_logger) + + ilog({ message: 'Connection established', context: { password: 's3cret', host: 'localhost' } }, log_src, lvl_info) + + content = File.read(log_file) + entry = JSON.parse(content.strip.split("\n").last) + + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('Connection established') + + expect(entry['context']['password']).to eq('s3cret') + expect(entry['context']['host']).to eq('localhost') + end + end + + describe 'log level filtering' do + it 'filters messages below the configured threshold' do + app = Msf::MCP::Application.new([], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { + logging: { + enabled: true, + level: 'WARN', + log_file: log_file, + sanitize: false + } + }) + app.send(:initialize_logger) + + # INFO (LEV_2) should be filtered at WARN (LEV_1) threshold + ilog({ message: 'This should be filtered' }, log_src, lvl_info) + # WARN (LEV_1) should pass + wlog({ message: 'This should appear' }, log_src, lvl_warn) + # ERROR (LEV_0) should pass + elog({ message: 'This error should appear' }, log_src, lvl_error) + + content = File.read(log_file) + lines = content.strip.split("\n").reject(&:empty?) + + expect(lines.length).to eq(2) + + entries = lines.map { |l| JSON.parse(l) } + expect(entries.map { |e| e['severity'] }).to contain_exactly('WARN', 'ERROR') + expect(content).not_to include('This should be filtered') + end + end + + describe 'exception logging through the pipeline' do + it 'formats exceptions as structured JSON with sanitized messages' do + app = Msf::MCP::Application.new([], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { + logging: { + enabled: true, + level: 'ERROR', + log_file: log_file, + sanitize: true + } + }) + app.send(:initialize_logger) + + error = StandardError.new('Failed with password=hunter2') + error.set_backtrace(['lib/msf/core/mcp/server.rb:42:in `start`']) + + elog({ message: 'Startup failed', exception: error }, log_src, lvl_error) + + content = File.read(log_file) + entry = JSON.parse(content.strip.split("\n").last) + + expect(entry['severity']).to eq('ERROR') + expect(entry['message']).to include('Startup failed') + expect(entry['exception']).to be_a(Hash) + expect(entry['exception']['class']).to eq('StandardError') + + expect(entry['exception']['message']).to include('[REDACTED]') + expect(entry['exception']['message']).not_to include('hunter2') + end + end +end diff --git a/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb b/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb new file mode 100644 index 0000000000000..7d4e82376f2cb --- /dev/null +++ b/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'MessagePack Authentication Flow Integration' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + + describe 'Successful Authentication' do + it 'authenticates with username and password' do + # Stub authentication endpoint + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token_12345' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + + token = client.authenticate(user, password) + expect(token).to eq('test_token_12345') + end + end + + describe 'Token Reuse' do + it 'stores token for subsequent API calls' do + # Stub authentication endpoint + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token_12345' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub subsequent API call with token + stub_request(:post, api_url) + .with(body: ['module.search', 'test_token_12345', 'smb'].to_msgpack) + .to_return( + status: 200, + body: [].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + + stored_token = client.authenticate(user, password) + + # Subsequent request should use the stored token + client.call_api('module.search', ['smb']) + + # Token should still be the same + expect(client.instance_variable_get(:@token)).to eq(stored_token) + expect(stored_token).to eq('test_token_12345') + end + end +end diff --git a/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb b/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb new file mode 100644 index 0000000000000..75d9b52abbb34 --- /dev/null +++ b/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'MessagePack Re-Authentication Flow Integration' do + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + + describe 'Automatic Re-Authentication on Token Expiry' do + it 're-authenticates and retries when API call returns 401' do + call_count = 0 + + # Stub all POST requests and dispatch based on body content + stub_request(:post, api_url).to_return do |request| + body = MessagePack.unpack(request.body) + call_count += 1 + + case call_count + when 1 + # Initial authentication succeeds + expect(body[0]).to eq('auth.login') + { + status: 200, + body: { 'result' => 'success', 'token' => 'initial_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + when 2 + # First API call returns 401 (token expired) + expect(body[0]).to eq('module.search') + expect(body[1]).to eq('initial_token') + { + status: 401, + body: { 'error_message' => 'Token expired' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + when 3 + # Re-authentication succeeds with new token + expect(body[0]).to eq('auth.login') + { + status: 200, + body: { 'result' => 'success', 'token' => 'refreshed_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + when 4 + # Retry with new token succeeds + expect(body[0]).to eq('module.search') + expect(body[1]).to eq('refreshed_token') + { + status: 200, + body: [{ 'fullname' => 'exploit/test', 'type' => 'exploit', 'name' => 'test' }].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + else + raise "Unexpected request ##{call_count}: #{body.inspect}" + end + end + + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + # This call should trigger: 401 → re-auth → retry → success + result = client.search_modules('smb') + + expect(result).to be_an(Array) + expect(result.first['fullname']).to eq('exploit/test') + expect(client.instance_variable_get(:@token)).to eq('refreshed_token') + expect(call_count).to eq(4) + end + + it 'gives up after max retries when re-auth succeeds but API keeps failing' do + call_count = 0 + + stub_request(:post, api_url).to_return do |request| + body = MessagePack.unpack(request.body) + call_count += 1 + + if body[0] == 'auth.login' + { + status: 200, + body: { 'result' => 'success', 'token' => "token_#{call_count}" }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + else + # API calls always return 401 + { + status: 401, + body: { 'error_message' => 'Token invalid' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + } + end + end + + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + # Should exhaust retries (max_retries=2) and re-raise + expect { + client.search_modules('smb') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError) + end + + it 'propagates re-auth failure through the tool layer as an error response' do + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # All subsequent requests return 401 + stub_request(:post, api_url) + .with { |req| MessagePack.unpack(req.body)[0] != 'auth.login' } + .to_return( + status: 401, + body: { 'error_message' => 'Token invalid' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60) + server_context = { msf_client: client, rate_limiter: limiter } + + result = Msf::MCP::Tools::SearchModules.call(query: 'smb', server_context: server_context) + + expect(result.error?).to be true + expect(result.content.first[:text]).to include('Authentication failed') + end + end +end diff --git a/spec/integration/msfmcpd/rate_limiting_spec.rb b/spec/integration/msfmcpd/rate_limiting_spec.rb new file mode 100644 index 0000000000000..6810dab9c675c --- /dev/null +++ b/spec/integration/msfmcpd/rate_limiting_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'Rate Limiting Integration' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + + describe 'Rate Limiting Across Multiple Tool HTTP Requests' do + it 'enforces rate limit across multiple tool calls with HTTP requests' do + # Stub authentication endpoint + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub module search endpoint + search_stub = stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_return( + status: 200, + body: [{ 'fullname' => 'auxiliary/scanner/smb/smb_version' }].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Create rate limiter with low limit + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 3, burst_size: 3) + + # Create authenticated client + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + # Create server context + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # First 3 tool calls should succeed (within rate limit) + 3.times do + expect { + Msf::MCP::Tools::SearchModules.call( + query: 'smb', + limit: 10, + server_context: server_context + ) + }.not_to raise_error + end + + # Verify exactly 3 HTTP requests were made (plus 1 for auth) + expect(search_stub).to have_been_requested.times(3) + + # 4th call should be rate limited before making HTTP request + result = Msf::MCP::Tools::SearchModules.call(query: 'smb', limit: 10, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + + # Verify still only 3 search requests (4th was blocked by rate limiter) + expect(search_stub).to have_been_requested.times(3) + end + + it 'applies global rate limit across different tools with HTTP calls' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub search modules + search_stub = stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_return( + status: 200, + body: [].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub db.hosts + hosts_stub = stub_request(:post, api_url) + .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack) + .to_return( + status: 200, + body: { 'hosts' => [] }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 5, burst_size: 5) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Make 3 search_modules calls + 3.times do + Msf::MCP::Tools::SearchModules.call( + query: 'smb', + limit: 10, + server_context: server_context + ) + end + + # Make 2 host_info calls + 2.times do + Msf::MCP::Tools::HostInfo.call( + workspace: 'default', + server_context: server_context + ) + end + + # 6th call (different tool) should be rate limited + result = Msf::MCP::Tools::SearchModules.call(query: 'http', limit: 10, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + + # Verify HTTP request counts + expect(search_stub).to have_been_requested.times(3) + expect(hosts_stub).to have_been_requested.times(2) + end + end +end diff --git a/spec/integration/msfmcpd/rpc_availability_spec.rb b/spec/integration/msfmcpd/rpc_availability_spec.rb new file mode 100644 index 0000000000000..2f57768b60a00 --- /dev/null +++ b/spec/integration/msfmcpd/rpc_availability_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'stringio' +require 'tempfile' + +RSpec.describe 'RPC Availability Integration' do + let(:output) { StringIO.new } + let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') } + let(:valid_messagepack_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') } + + describe 'Application run with RPC already available but no credentials' do + it 'exits with RPC startup error when MessagePack credentials are missing' do + # Config with no credentials — validator allows this because auto-start + # could generate them, but RPC is already running so generation won't happen + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + auto_start_rpc: true + } + } + + config_file = Tempfile.new(['no_creds', '.yaml']) + config_file.write(YAML.dump(JSON.parse(config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + # Stub RPC as already available + allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + + expect(output.string).to include('RPC startup error') + expect(output.string).to include('no credentials') + + config_file.close + config_file.unlink + end + + it 'exits with RPC startup error when JSON-RPC token is missing' do + config = { + msf_api: { + type: 'json-rpc', + host: 'localhost', + port: 8081 + } + } + + config_file = Tempfile.new(['no_token', '.yaml']) + config_file.write(YAML.dump(JSON.parse(config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + # Stub RPC as already available + allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + + # The validator catches missing token before RpcManager runs + expect(output.string).to match(/token|Configuration validation failed/i) + + config_file.close + config_file.unlink + end + + it 'proceeds when RPC is available and credentials are provided' do + app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output) + + # Stub RPC as already available + allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true) + + # Stub the rest of the startup sequence + mock_client = instance_double(Msf::MCP::Metasploit::Client) + allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:authenticate) + mock_server = instance_double(Msf::MCP::Server) + allow(Msf::MCP::Server).to receive(:new).and_return(mock_server) + allow(mock_server).to receive(:start) + allow(Signal).to receive(:trap) + + expect { app.run }.not_to raise_error + + expect(output.string).to include('already running') + expect(output.string).to include('Authentication successful') + end + end + + describe 'Application run with RPC not available' do + it 'exits with RPC startup error when auto-start is disabled' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + user: 'msf', + password: 'pass', + auto_start_rpc: false + } + } + + config_file = Tempfile.new(['no_autostart', '.yaml']) + config_file.write(YAML.dump(JSON.parse(config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + # Stub RPC as not available + allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(false) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + + expect(output.string).to include('RPC startup error') + expect(output.string).to include('auto-start is disabled') + + config_file.close + config_file.unlink + end + + it 'exits with RPC startup error on remote host' do + config = { + msf_api: { + type: 'messagepack', + host: '192.0.2.1', + port: 55553, + user: 'msf', + password: 'pass', + auto_start_rpc: true + } + } + + config_file = Tempfile.new(['remote_host', '.yaml']) + config_file.write(YAML.dump(JSON.parse(config.to_json))) + config_file.flush + + app = Msf::MCP::Application.new(['--config', config_file.path], output: output) + + # Stub RPC as not available + allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(false) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + + expect(output.string).to include('RPC startup error') + expect(output.string).to include('192.0.2.1') + + config_file.close + config_file.unlink + end + end +end diff --git a/spec/integration/msfmcpd/tool_execution_db_spec.rb b/spec/integration/msfmcpd/tool_execution_db_spec.rb new file mode 100644 index 0000000000000..b9c58a50ab43e --- /dev/null +++ b/spec/integration/msfmcpd/tool_execution_db_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'Tool Execution End-to-End - Database Queries' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + + describe 'Database Query Integration with HTTP' do + it 'executes host query through complete HTTP request flow' do + # Stub authentication endpoint + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub db.hosts endpoint with realistic response + hosts_stub = stub_request(:post, api_url) + .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack) + .to_return( + status: 200, + body: { + 'hosts' => [ + { + 'address' => '192.168.1.100', + 'mac' => '00:11:22:33:44:55', + 'name' => 'server01', + 'os_name' => 'Linux', + 'os_flavor' => 'Ubuntu', + 'state' => 'alive', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + }, + { + 'address' => '192.168.1.101', + 'mac' => '00:11:22:33:44:56', + 'name' => 'server02', + 'os_name' => 'Windows', + 'os_flavor' => 'Server 2019', + 'state' => 'alive', + 'created_at' => 1609459300, + 'updated_at' => 1640995300 + } + ] + }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Create rate limiter + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + + # Create authenticated client + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + # Create server context + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute host query through complete stack + result = Msf::MCP::Tools::HostInfo.call( + workspace: 'default', + server_context: server_context + ) + + # Verify HTTP request was made + expect(hosts_stub).to have_been_requested.once + + # Verify MCP response structure + expect(result).to be_a(MCP::Tool::Response) + expect(result.content).to be_an(Array) + expect(result.content.first[:type]).to eq('text') + + # Verify data transformation occurred correctly + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:address]).to eq('192.168.1.100') + expect(data.first[:hostname]).to eq('server01') + expect(data.first[:os_name]).to eq('Linux') + + # Verify timestamps transformed to ISO 8601 + expect(data.first[:created_at]).to eq('2021-01-01T00:00:00Z') + expect(data.first[:updated_at]).to eq('2022-01-01T00:00:00Z') + + # Verify metadata + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + end + + it 'applies filters correctly through HTTP request' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub db.hosts with filters + hosts_stub = stub_request(:post, api_url) + .with(body: ['db.hosts', 'test_token', { workspace: 'default', addresses: '192.168.1.0/24', only_up: true }].to_msgpack) + .to_return( + status: 200, + body: { + 'hosts' => [ + { + 'address' => '192.168.1.100', + 'mac' => '00:11:22:33:44:55', + 'name' => 'filtered_host', + 'state' => 'alive', + 'created_at' => 1609459200 + } + ] + }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute query with filters + result = Msf::MCP::Tools::HostInfo.call( + workspace: 'default', + addresses: '192.168.1.0/24', + only_up: true, + server_context: server_context + ) + + # Verify HTTP request with filters was made + expect(hosts_stub).to have_been_requested.once + + # Verify filtered results + expect(result).to be_a(MCP::Tool::Response) + data = result.structured_content[:data] + expect(data.length).to eq(1) + expect(data.first[:address]).to eq('192.168.1.100') + end + + it 'executes service query with multiple filters through HTTP' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub db.services with filters + # Note: MessagePack hash key order may vary, so we match any request to db.services + services_stub = stub_request(:post, api_url) + .with { |request| + body = MessagePack.unpack(request.body) + body[0] == 'db.services' && body[1] == 'test_token' && + body[2].is_a?(Hash) && body[2]['workspace'] == 'default' + } + .to_return( + status: 200, + body: { + 'services' => [ + { + 'host' => '192.168.1.100', + 'port' => 445, + 'proto' => 'tcp', + 'name' => 'microsoft-ds', + 'state' => 'open', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + } + ] + }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute service query with multiple filters + result = Msf::MCP::Tools::ServiceInfo.call( + workspace: 'default', + host: '192.168.1.100', + ports: '445', + protocol: 'tcp', + server_context: server_context + ) + + # Verify HTTP request with all filters was made + expect(services_stub).to have_been_requested.once + + # Verify results + expect(result).to be_a(MCP::Tool::Response) + data = result.structured_content[:data] + expect(data.length).to eq(1) + expect(data.first[:host_address]).to eq('192.168.1.100') + expect(data.first[:port]).to eq(445) + expect(data.first[:protocol]).to eq('tcp') + expect(data.first[:name]).to eq('microsoft-ds') + end + + it 'handles pagination correctly across HTTP boundary' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub db.hosts with multiple results + hosts_stub = stub_request(:post, api_url) + .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack) + .to_return( + status: 200, + body: { + 'hosts' => [ + { 'address' => '192.168.1.1', 'name' => 'host1', 'state' => 'alive', 'created_at' => 1609459200 }, + { 'address' => '192.168.1.2', 'name' => 'host2', 'state' => 'alive', 'created_at' => 1609459300 }, + { 'address' => '192.168.1.3', 'name' => 'host3', 'state' => 'alive', 'created_at' => 1609459400 }, + { 'address' => '192.168.1.4', 'name' => 'host4', 'state' => 'alive', 'created_at' => 1609459500 }, + { 'address' => '192.168.1.5', 'name' => 'host5', 'state' => 'alive', 'created_at' => 1609459600 } + ] + }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute query with pagination (offset=1, limit=2 means items 2 and 3) + result = Msf::MCP::Tools::HostInfo.call( + workspace: 'default', + limit: 2, + offset: 1, + server_context: server_context + ) + + # Verify HTTP request was made + expect(hosts_stub).to have_been_requested.once + + # Verify pagination applied correctly + expect(result).to be_a(MCP::Tool::Response) + data = result.structured_content[:data] + expect(data.length).to eq(2) + expect(data.first[:address]).to eq('192.168.1.2') + expect(data.last[:address]).to eq('192.168.1.3') + + # Verify pagination metadata + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(2) + expect(metadata[:offset]).to eq(1) + expect(metadata[:total_items]).to eq(5) + expect(metadata[:returned_items]).to eq(2) + end + end +end diff --git a/spec/integration/msfmcpd/tool_execution_module_info_spec.rb b/spec/integration/msfmcpd/tool_execution_module_info_spec.rb new file mode 100644 index 0000000000000..28061127b8026 --- /dev/null +++ b/spec/integration/msfmcpd/tool_execution_module_info_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'Tool Execution End-to-End - Module Info' do + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + + let(:limiter) { Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) } + let(:client) do + c = Msf::MCP::Metasploit::MessagePackClient.new(host: host, port: port, endpoint: endpoint) + c.authenticate(user, password) + c + end + let(:server_context) { { msf_client: client, rate_limiter: limiter } } + + before do + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + end + + describe 'Module Info Integration with HTTP' do + it 'retrieves module info through complete HTTP request flow' do + info_stub = stub_request(:post, api_url) + .with(body: ['module.info', 'test_token', 'exploit', 'windows/smb/ms17_010_eternalblue'].to_msgpack) + .to_return( + status: 200, + body: { + 'type' => 'exploit', + 'name' => 'MS17-010 EternalBlue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'rank' => 'excellent', + 'disclosuredate' => '2017-03-14', + 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption', + 'license' => 'MSF_LICENSE', + 'filepath' => '/opt/metasploit-framework/modules/exploits/windows/smb/ms17_010_eternalblue.rb', + 'arch' => ['x64', 'x86'], + 'platform' => ['windows'], + 'authors' => ['Author1', 'Author2'], + 'privileged' => true, + 'check' => true, + 'references' => [['CVE', '2017-0144'], ['URL', 'https://example.com']], + 'targets' => { 0 => 'Windows 7', 1 => 'Windows 8' }, + 'default_target' => 0, + 'options' => { 'RHOSTS' => { 'type' => 'address', 'required' => true } } + }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + result = Msf::MCP::Tools::ModuleInfo.call( + type: 'exploit', + name: 'windows/smb/ms17_010_eternalblue', + server_context: server_context + ) + + expect(info_stub).to have_been_requested.once + + expect(result).to be_a(MCP::Tool::Response) + expect(result.error?).to be false + + data = result.structured_content[:data] + expect(data[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue') + expect(data[:rank]).to eq('excellent') + expect(data[:architectures]).to eq(['x64', 'x86']) + expect(data[:has_check_method]).to be true + + # Verify filepath is stripped of install path + expect(data[:filepath]).to eq('modules/exploits/windows/smb/ms17_010_eternalblue.rb') + expect(data[:filepath]).not_to include('/opt/metasploit-framework/') + + # Verify references are transformed + expect(data[:references]).to eq([ + { type: 'CVE', value: '2017-0144' }, + { type: 'URL', value: 'https://example.com' } + ]) + + # Verify metadata + expect(result.structured_content[:metadata][:query_time]).to be_a(Float) + end + + it 'handles module not found through HTTP' do + stub_request(:post, api_url) + .with(body: ['module.info', 'test_token', 'exploit', 'nonexistent/module'].to_msgpack) + .to_return( + status: 500, + body: { 'error_message' => 'Module not found' }.to_msgpack + ) + + result = Msf::MCP::Tools::ModuleInfo.call( + type: 'exploit', + name: 'nonexistent/module', + server_context: server_context + ) + + expect(result.error?).to be true + expect(result.content.first[:text]).to include('Metasploit API error') + end + + it 'validates module type before making HTTP request' do + result = Msf::MCP::Tools::ModuleInfo.call( + type: 'invalid_type', + name: 'windows/smb/ms17_010_eternalblue', + server_context: server_context + ) + + expect(result.error?).to be true + expect(result.content.first[:text]).to include('Module type') + end + end +end diff --git a/spec/integration/msfmcpd/tool_execution_search_spec.rb b/spec/integration/msfmcpd/tool_execution_search_spec.rb new file mode 100644 index 0000000000000..18b05a62aacc9 --- /dev/null +++ b/spec/integration/msfmcpd/tool_execution_search_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'webmock/rspec' + +RSpec.describe 'Tool Execution End-to-End - Search Modules' do + # Disable real HTTP connections for integration tests + before(:all) do + WebMock.disable_net_connect!(allow_localhost: false) + end + + after(:all) do + WebMock.allow_net_connect! + end + + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:endpoint) { '/api/' } + let(:api_url) { "https://#{host}:#{port}#{endpoint}" } + let(:user) { 'test_user' } + let(:password) { 'test_password' } + + describe 'Module Search Integration with HTTP' do + it 'executes module search through complete HTTP request flow' do + # Stub authentication endpoint + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub module search endpoint with realistic response + # Note: The MessagePackClient returns the unpacked response directly, + # so we need to return an array, not a hash with 'modules' key + search_stub = stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'smb'].to_msgpack) + .to_return( + status: 200, + body: [ + { + 'name' => 'ms17_010_eternalblue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'type' => 'exploit', + 'rank' => 'excellent', + 'disclosuredate' => '2017-03-14', + 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption' + }, + { + 'name' => 'smb_version', + 'fullname' => 'auxiliary/scanner/smb/smb_version', + 'type' => 'auxiliary', + 'rank' => 'normal', + 'description' => 'SMB Version Detection' + } + ].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Create rate limiter + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + + # Create authenticated client + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + # Create server context + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute search through complete stack + result = Msf::MCP::Tools::SearchModules.call( + query: 'smb', + server_context: server_context + ) + + # Verify HTTP request was made + expect(search_stub).to have_been_requested.once + + # Verify MCP response structure + expect(result).to be_a(MCP::Tool::Response) + expect(result.content).to be_an(Array) + expect(result.content.first[:type]).to eq('text') + + # Verify data transformation occurred correctly + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue') + expect(data.first[:type]).to eq('exploit') + + # Verify metadata + metadata = result.structured_content[:metadata] + expect(metadata[:query]).to eq('smb') + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + end + + it 'handles empty search results through complete HTTP flow' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub search with empty results + search_stub = stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'nonexistent_module_xyz'].to_msgpack) + .to_return( + status: 200, + body: [].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute search + result = Msf::MCP::Tools::SearchModules.call( + query: 'nonexistent_module_xyz', + server_context: server_context + ) + + # Verify HTTP request was made + expect(search_stub).to have_been_requested.once + + # Verify empty results handled correctly + expect(result).to be_a(MCP::Tool::Response) + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'applies pagination correctly through HTTP request' do + # Stub authentication + stub_request(:post, api_url) + .with(body: ['auth.login', user, password].to_msgpack) + .to_return( + status: 200, + body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + # Stub search with multiple results + search_stub = stub_request(:post, api_url) + .with(body: ['module.search', 'test_token', 'scanner'].to_msgpack) + .to_return( + status: 200, + body: [ + { 'fullname' => 'auxiliary/scanner/http/http_version', 'type' => 'auxiliary', 'name' => 'http_version' }, + { 'fullname' => 'auxiliary/scanner/smb/smb_version', 'type' => 'auxiliary', 'name' => 'smb_version' }, + { 'fullname' => 'auxiliary/scanner/ssh/ssh_version', 'type' => 'auxiliary', 'name' => 'ssh_version' }, + { 'fullname' => 'auxiliary/scanner/ftp/ftp_version', 'type' => 'auxiliary', 'name' => 'ftp_version' } + ].to_msgpack, + headers: { 'Content-Type' => 'binary/message-pack' } + ) + + limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) + client = Msf::MCP::Metasploit::MessagePackClient.new( + host: host, + port: port, + endpoint: endpoint + ) + client.authenticate(user, password) + + server_context = { + msf_client: client, + rate_limiter: limiter + } + + # Execute search with pagination + result = Msf::MCP::Tools::SearchModules.call( + query: 'scanner', + limit: 2, + offset: 1, + server_context: server_context + ) + + # Verify HTTP request was made + expect(search_stub).to have_been_requested.once + + # Verify pagination applied correctly (offset=1, limit=2 means items 2 and 3) + expect(result).to be_a(MCP::Tool::Response) + data = result.structured_content[:data] + expect(data.length).to eq(2) + expect(data.first[:fullname]).to eq('auxiliary/scanner/smb/smb_version') + expect(data.last[:fullname]).to eq('auxiliary/scanner/ssh/ssh_version') + + # Verify pagination metadata + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(2) + expect(metadata[:offset]).to eq(1) + expect(metadata[:total_items]).to eq(4) + expect(metadata[:returned_items]).to eq(2) + end + end +end diff --git a/spec/lib/msf/core/mcp/application_spec.rb b/spec/lib/msf/core/mcp/application_spec.rb new file mode 100644 index 0000000000000..c5bfcc97bc22a --- /dev/null +++ b/spec/lib/msf/core/mcp/application_spec.rb @@ -0,0 +1,873 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'stringio' +require 'tempfile' + +RSpec.describe Msf::MCP::Application do + let(:output) { StringIO.new } + let(:valid_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + ssl: true, + endpoint: '/api/', + user: 'testuser', + password: 'testpass' + }, + mcp: { + transport: 'stdio' + }, + rate_limit: { + requests_per_minute: 60, + burst_size: 10 + } + } + end + + describe '#initialize' do + it 'initializes with default values' do + app = described_class.new([], output: output) + + expect(app.options[:config_path]).to be_nil + # Logging options are no longer in defaults, they come from config file + expect(app.options[:enable_logging_cli]).to be_nil + expect(app.options[:log_file_cli]).to be_nil + end + + it 'accepts custom output stream' do + # Verify output is used - instantiation doesn't trigger help automatically + app = described_class.new([], output: output) + expect(app).to be_a(described_class) + + # Test that parse_arguments with --help writes to output + help_output = StringIO.new + help_app = described_class.new(['--help'], output: help_output) + expect { help_app.send(:parse_arguments) }.to raise_error(SystemExit) + expect(help_output.string).to include('MSF MCP Server') + end + end + + describe '#parse_arguments' do + it 'parses --config argument' do + app = described_class.new(['--config', '/custom/path/config.yml'], output: output) + app.send(:parse_arguments) + + expect(app.options[:config_path]).to eq('/custom/path/config.yml') + end + + it 'parses --enable-logging argument' do + app = described_class.new(['--enable-logging'], output: output) + app.send(:parse_arguments) + + expect(app.options[:enable_logging_cli]).to be true + end + + it 'parses --log-file argument' do + app = described_class.new(['--log-file', 'custom.log'], output: output) + app.send(:parse_arguments) + + expect(app.options[:log_file_cli]).to eq('custom.log') + end + + it 'exits with help message on --help' do + app = described_class.new(['--help'], output: output) + + expect { app.send(:parse_arguments) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(0) + end + expect(output.string).to include('MSF MCP Server') + expect(output.string).to include('Usage:') + end + + it 'exits with version on --version' do + app = described_class.new(['--version'], output: output) + + expect { app.send(:parse_arguments) }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(0) + end + expect(output.string).to include('msfmcp version') + end + + it 'accepts short form -h for help' do + app = described_class.new(['-h'], output: output) + + expect { app.send(:parse_arguments) }.to raise_error(SystemExit) + expect(output.string).to include('MSF MCP Server') + end + + it 'accepts short form -v for version' do + app = described_class.new(['-v'], output: output) + + expect { app.send(:parse_arguments) }.to raise_error(SystemExit) + expect(output.string).to include('msfmcp version') + end + + it 'parses --no-auto-start-rpc argument' do + app = described_class.new(['--no-auto-start-rpc'], output: output) + app.send(:parse_arguments) + + expect(app.options[:no_auto_start_rpc]).to be true + end + + it 'does not set no_auto_start_rpc by default' do + app = described_class.new([], output: output) + app.send(:parse_arguments) + + expect(app.options[:no_auto_start_rpc]).to be_nil + end + end + + describe '#initialize_logger' do + let(:log_file) { Tempfile.new('app_test_log').tap(&:close).path } + + after do + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + File.delete(log_file) if File.exist?(log_file) + end + + it 'does not register a Rex source when logging is disabled' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, {}) + app.send(:initialize_logger) + + expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be false + end + + it 'registers the Rex source when --enable-logging is set' do + app = described_class.new(['--enable-logging', '--log-file', log_file], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { logging: { enabled: false, level: 'INFO', sanitize: false } }) + app.send(:initialize_logger) + + expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be true + end + + it 'registers the Rex source when logging.enabled is true in config' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } }) + app.send(:initialize_logger) + + expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be true + end + + it 'uses CLI log file path over config file path' do + cli_log = Tempfile.new('cli_log').tap(&:close).path + app = described_class.new(['--log-file', cli_log], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } }) + app.send(:initialize_logger) + + # Emit a message and confirm it went to the CLI path, not the config path + ilog('probe', Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0) + expect(File.read(cli_log)).to include('probe') + expect(File.read(log_file)).to be_empty + + deregister_log_source(Msf::MCP::LOG_SOURCE) + File.delete(cli_log) if File.exist?(cli_log) + end + + it 'wraps the sink with Sanitizing when sanitize is true' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: true } }) + app.send(:initialize_logger) + + # Log a message containing a sensitive pattern and verify it is redacted + elog({ message: 'password= s3cret' }, Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0) + content = File.read(log_file) + expect(content).to include('[REDACTED]') + expect(content).not_to include('s3cret') + end + + it 'does not wrap with Sanitizing when sanitize is false' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } }) + app.send(:initialize_logger) + + # Log a message containing a sensitive pattern — should appear as-is + elog({ message: 'password= s3cret' }, Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0) + content = File.read(log_file) + expect(content).to include('s3cret') + end + end + + describe '#install_signal_handlers' do + it 'installs signal handlers for INT and TERM' do + app = described_class.new([], output: output) + + # Mock Signal.trap to avoid actually installing handlers in tests + expect(Signal).to receive(:trap).with('INT') + expect(Signal).to receive(:trap).with('TERM') + + app.send(:install_signal_handlers) + end + end + + describe '#load_configuration' do + it 'loads configuration from file' do + config_file = Tempfile.new(['config', '.yml']) + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(valid_config.to_json))) + config_file.flush + + app = described_class.new(['--config', config_file.path], output: output) + app.send(:parse_arguments) + app.send(:load_configuration) + + expect(app.config).to be_a(Hash) + expect(app.config[:msf_api][:type]).to eq('messagepack') + + config_file.close + config_file.unlink + end + + it 'outputs loading message' do + config_file = Tempfile.new(['config', '.yml']) + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(valid_config.to_json))) + config_file.flush + + app = described_class.new(['--config', config_file.path], output: output) + app.send(:parse_arguments) + app.send(:load_configuration) + + expect(output.string).to include("Loading configuration from #{config_file.path}") + + config_file.close + config_file.unlink + end + + it 'raises error for missing file' do + app = described_class.new(['--config', '/nonexistent/config.yml'], output: output) + app.send(:parse_arguments) + + expect { app.send(:load_configuration) }.to raise_error(Msf::MCP::Config::ConfigurationError, /not found/) + end + end + + describe '#validate_configuration' do + it 'validates configuration successfully' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + expect { app.send(:validate_configuration) }.not_to raise_error + expect(output.string).to include('Validating configuration...') + expect(output.string).to include('Configuration valid') + end + + it 'raises error for invalid configuration' do + app = described_class.new([], output: output) + # Use a config with an actual validation error (invalid enum value) + app.instance_variable_set(:@config, { msf_api: { type: 'invalid_type' } }) + + expect { app.send(:validate_configuration) }.to raise_error(Msf::MCP::Config::ValidationError) + end + end + + describe '#initialize_rate_limiter' do + it 'creates rate limiter with config values' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + app.send(:initialize_rate_limiter) + + expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter) + expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60) + end + + it 'uses default values when rate_limit config is missing' do + config_without_rate_limit = valid_config.dup + config_without_rate_limit.delete(:rate_limit) + + app = described_class.new([], output: output) + app.instance_variable_set(:@config, config_without_rate_limit) + app.send(:initialize_rate_limiter) + + expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter) + expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60) + end + end + + describe '#initialize_metasploit_client' do + it 'creates Metasploit client with config values' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + # Mock the Client.new to avoid actual connection + mock_client = instance_double(Msf::MCP::Metasploit::Client) + expect(Msf::MCP::Metasploit::Client).to receive(:new).with( + api_type: 'messagepack', + host: 'localhost', + port: 55553, + ssl: true, + endpoint: '/api/', + token: nil + ).and_return(mock_client) + + app.send(:initialize_metasploit_client) + + expect(app.msf_client).to eq(mock_client) + expect(output.string).to include('Connecting to Metasploit RPC at localhost:55553') + end + end + + describe '#authenticate_metasploit' do + let(:mock_client) { instance_double(Msf::MCP::Metasploit::Client) } + + before do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + app.instance_variable_set(:@msf_client, mock_client) + end + + it 'authenticates when using MessagePack' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + app.instance_variable_set(:@msf_client, mock_client) + + expect(mock_client).to receive(:authenticate).with('testuser', 'testpass') + + app.send(:authenticate_metasploit) + + expect(output.string).to include('Authenticating with Metasploit...') + expect(output.string).to include('Authentication successful') + end + + it 'skips authentication when using JSON-RPC' do + json_rpc_config = valid_config.dup + json_rpc_config[:msf_api][:type] = 'json-rpc' + json_rpc_config[:msf_api][:token] = 'test_token' + + app = described_class.new([], output: output) + app.instance_variable_set(:@config, json_rpc_config) + app.instance_variable_set(:@msf_client, mock_client) + + expect(mock_client).not_to receive(:authenticate) + + app.send(:authenticate_metasploit) + + expect(output.string).to include('Using JSON-RPC with token authentication') + end + end + + describe '#initialize_mcp_server' do + it 'creates MCP server with dependencies' 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) + + expect(Msf::MCP::Server).to receive(:new).with( + msf_client: mock_client, + rate_limiter: mock_rate_limiter + ).and_return(mock_mcp_server) + + app.send(:initialize_mcp_server) + + expect(app.mcp_server).to eq(mock_mcp_server) + expect(output.string).to include('Initializing MCP server...') + end + end + + describe '#start_mcp_server' do + let(:mock_mcp_server) { instance_double(Msf::MCP::Server) } + + it 'starts server with stdio transport by default' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + app.instance_variable_set(:@mcp_server, mock_mcp_server) + + expect(mock_mcp_server).to receive(:start).with(transport: :stdio) + + app.send(:start_mcp_server) + + expect(output.string).to include('Starting MCP server on stdio transport...') + expect(output.string).to include('Server ready - waiting for MCP requests') + end + + it 'starts server with HTTP transport when configured' do + http_config = valid_config.dup + http_config[:mcp] = { + transport: 'http', + host: '0.0.0.0', + port: 3000 + } + + app = described_class.new([], output: output) + app.instance_variable_set(:@config, http_config) + app.instance_variable_set(:@mcp_server, mock_mcp_server) + + expect(mock_mcp_server).to receive(:start).with(transport: :http, host: '0.0.0.0', port: 3000) + + app.send(:start_mcp_server) + + expect(output.string).to include('Starting MCP server on HTTP transport...') + expect(output.string).to include('Server listening on http://0.0.0.0:3000') + end + + it 'uses default host and port for HTTP transport' do + http_config = valid_config.dup + http_config[:mcp] = { transport: 'http' } + + app = described_class.new([], output: output) + app.instance_variable_set(:@config, http_config) + app.instance_variable_set(:@mcp_server, mock_mcp_server) + + expect(mock_mcp_server).to receive(:start).with(transport: :http, host: 'localhost', port: 3000) + + app.send(:start_mcp_server) + end + end + + describe '#shutdown' do + it 'outputs shutdown complete' do + app = described_class.new([], output: output) + app.shutdown('INT') + + expect(output.string).to include('Shutdown complete') + end + + it 'does not raise when no Rex sink is registered' do + app = described_class.new([], output: output) + expect { app.shutdown('TERM') }.not_to raise_error + end + + it 'calls shutdown on mcp_server when present' do + mock_mcp_server = instance_double(Msf::MCP::Server) + + app = described_class.new([], output: output) + app.instance_variable_set(:@mcp_server, mock_mcp_server) + + expect(mock_mcp_server).to receive(:shutdown) + + app.shutdown('INT') + + expect(output.string).to include('Shutdown complete') + end + + it 'calls stop_rpc_server on rpc_manager when present' do + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + + app = described_class.new([], output: output) + app.instance_variable_set(:@rpc_manager, mock_rpc_manager) + + expect(mock_rpc_manager).to receive(:stop_rpc_server) + + app.shutdown('INT') + end + + it 'handles nil mcp_server gracefully' do + app = described_class.new([], output: output) + app.instance_variable_set(:@mcp_server, nil) + + expect { app.shutdown('INT') }.not_to raise_error + expect(output.string).to include('Shutdown complete') + end + + it 'handles nil rpc_manager gracefully' do + app = described_class.new([], output: output) + app.instance_variable_set(:@rpc_manager, nil) + + expect { app.shutdown('INT') }.not_to raise_error + expect(output.string).to include('Shutdown complete') + end + end + + describe '#ensure_rpc_server' do + context 'when RPC is already available' do + it 'does not create an RPC manager' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + app.send(:ensure_rpc_server) + + expect(app.rpc_manager).to eq(mock_rpc_manager) + end + + it 'calls ensure_rpc_available on the manager' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + + expect(mock_rpc_manager).to receive(:ensure_rpc_available) + + app.send(:ensure_rpc_server) + end + end + + context 'when --no-auto-start-rpc is set' do + it 'sets auto_start_rpc to false in config during load_configuration' do + app = described_class.new(['--no-auto-start-rpc'], output: output) + app.send(:parse_arguments) + app.instance_variable_set(:@config, valid_config.dup) + + # Simulate load_configuration CLI override + app.send(:load_configuration) rescue nil + # Directly verify the config was updated by setting it up properly + app = described_class.new(['--no-auto-start-rpc'], output: output) + app.send(:parse_arguments) + config = valid_config.dup + config[:msf_api] = config[:msf_api].dup + app.instance_variable_set(:@config, config) + + # Apply the override as load_configuration would + config[:msf_api][:auto_start_rpc] = false if app.options[:no_auto_start_rpc] + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + expect(Msf::MCP::RpcManager).to receive(:new).with( + hash_including(config: hash_including(msf_api: hash_including(auto_start_rpc: false))) + ).and_return(mock_rpc_manager) + + app.send(:ensure_rpc_server) + end + end + + context 'when RPC startup fails' do + it 'propagates RpcStartupError' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise( + Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found') + ) + + expect { app.send(:ensure_rpc_server) }.to raise_error(Msf::MCP::Metasploit::RpcStartupError) + end + end + + context 'when connection times out' do + it 'propagates ConnectionError' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise( + Msf::MCP::Metasploit::ConnectionError.new('Timed out waiting for RPC server') + ) + + expect { app.send(:ensure_rpc_server) }.to raise_error(Msf::MCP::Metasploit::ConnectionError) + end + end + + it 'passes output to RpcManager' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + expect(Msf::MCP::RpcManager).to receive(:new).with( + hash_including(output: output) + ).and_return(mock_rpc_manager) + + app.send(:ensure_rpc_server) + end + end + + describe 'error handlers' do + describe '#handle_configuration_error' do + it 'outputs error message and exits' do + app = described_class.new([], output: output) + error = Msf::MCP::Config::ValidationError.new({ 'msf_api.type': 'is invalid' }) + + expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Configuration validation failed') + end + + it 'handles ConfigurationError the same way' do + app = described_class.new([], output: output) + error = Msf::MCP::Config::ConfigurationError.new('Configuration file not found: /missing.yml') + + expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Configuration file not found') + end + + it 'does not call elog (logger not available yet)' do + app = described_class.new([], output: output) + error = Msf::MCP::Config::ValidationError.new({}) + + # elog should not be called — logger is not initialized at this stage + expect(app).not_to receive(:elog) + expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit) + end + end + + describe '#handle_connection_error' do + it 'outputs connection error with host and port' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + error = Msf::MCP::Metasploit::ConnectionError.new('Connection refused') + + expect { app.send(:handle_connection_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Connection error to Metasploit RPC at localhost:55553') + expect(output.string).to include('Connection refused') + end + + it 'logs the error via elog' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + error = Msf::MCP::Metasploit::ConnectionError.new('Connection refused') + + expect(app).to receive(:elog).with(hash_including(message: 'Connection error'), anything, anything) + expect { app.send(:handle_connection_error, error) }.to raise_error(SystemExit) + end + end + + describe '#handle_api_error' do + it 'outputs API error message' do + app = described_class.new([], output: output) + error = Msf::MCP::Metasploit::APIError.new('Invalid method') + + expect { app.send(:handle_api_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Metasploit API error: Invalid method') + end + + it 'logs the error via elog' do + app = described_class.new([], output: output) + error = Msf::MCP::Metasploit::APIError.new('Invalid method') + + expect(app).to receive(:elog).with(hash_including(message: 'Metasploit API error'), anything, anything) + expect { app.send(:handle_api_error, error) }.to raise_error(SystemExit) + end + end + + describe '#handle_authentication_error' do + it 'outputs authentication error with username' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + error = Msf::MCP::Metasploit::AuthenticationError.new('Login Failed') + + expect { app.send(:handle_authentication_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Authentication error (username: testuser): Login Failed') + end + + it 'logs the error via elog' do + app = described_class.new([], output: output) + app.instance_variable_set(:@config, valid_config) + error = Msf::MCP::Metasploit::AuthenticationError.new('Login Failed') + + expect(app).to receive(:elog).with(hash_including(message: 'Authentication error'), anything, anything) + expect { app.send(:handle_authentication_error, error) }.to raise_error(SystemExit) + end + end + + describe '#handle_rpc_startup_error' do + it 'outputs RPC startup error message and exits' do + app = described_class.new([], output: output) + error = Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found') + + expect { app.send(:handle_rpc_startup_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('RPC startup error: msfrpcd not found') + end + + it 'logs the error via elog' do + app = described_class.new([], output: output) + error = Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found') + + expect(app).to receive(:elog).with(hash_including(message: 'RPC startup error'), anything, anything) + expect { app.send(:handle_rpc_startup_error, error) }.to raise_error(SystemExit) + end + end + + describe '#handle_fatal_error' do + it 'outputs error message and backtrace' do + app = described_class.new([], output: output) + error = StandardError.new('Unexpected error') + error.set_backtrace(['line1', 'line2', 'line3', 'line4', 'line5', 'line6']) + + expect { app.send(:handle_fatal_error, error) }.to raise_error(SystemExit) do |e| + expect(e.status).to eq(1) + end + expect(output.string).to include('Fatal error: Unexpected error') + expect(output.string).to include('line1') + expect(output.string).to include('line5') + expect(output.string).not_to include('line6') # Only first 5 lines + end + + it 'logs the error via elog' do + app = described_class.new([], output: output) + error = StandardError.new('Unexpected error') + + expect(app).to receive(:elog).with(hash_including(message: 'Fatal error during startup'), anything, anything) + expect { app.send(:handle_fatal_error, error) }.to raise_error(SystemExit) + end + end + end + + describe '#run' do + let(:config_file) { Tempfile.new(['config', '.yml']) } + let(:mock_mcp_server) { instance_double(Msf::MCP::Server) } + let(:mock_client) { instance_double(Msf::MCP::Metasploit::Client) } + + before do + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(valid_config.to_json))) + config_file.flush + end + + after do + config_file.close + config_file.unlink + end + + it 'runs through the complete startup sequence successfully' do + app = described_class.new(['--config', config_file.path], output: output) + + # Mock RPC manager + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + # Mock external dependencies + allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:authenticate) + allow(Msf::MCP::Server).to receive(:new).and_return(mock_mcp_server) + allow(mock_mcp_server).to receive(:start) + + # Mock signal handlers to avoid actual installation + allow(Signal).to receive(:trap) + + app.run + + # Verify all initialization steps occurred + expect(output.string).to include('Loading configuration') + expect(output.string).to include('Validating configuration') + expect(output.string).to include('Configuration valid') + expect(output.string).to include('Connecting to Metasploit RPC') + expect(output.string).to include('Authenticating with Metasploit') + expect(output.string).to include('Authentication successful') + expect(output.string).to include('Initializing MCP server') + expect(output.string).to include('Starting MCP server') + end + + it 'handles configuration errors gracefully' do + bad_config = { msf_api: {} } + config_file.rewind + # Dirty hack to make sure the config hash keys are strings and not symbols. + config_file.write(YAML.dump(JSON.parse(bad_config.to_json))) + config_file.flush + + app = described_class.new(['--config', config_file.path], output: output) + + expect { app.run }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + expect(output.string).to include('Configuration validation failed') + end + + it 'handles missing config file gracefully' do + app = described_class.new(['--config', '/nonexistent/config.yml'], output: output) + + expect { app.run }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + expect(output.string).to include('Configuration file not found') + end + + it 'handles authentication errors gracefully' do + app = described_class.new(['--config', config_file.path], output: output) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:authenticate).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Login Failed') + ) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + expect(output.string).to include('Authentication error') + expect(output.string).to include('Login Failed') + end + + it 'handles connection errors gracefully' do + app = described_class.new(['--config', config_file.path], output: output) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:authenticate).and_raise( + Msf::MCP::Metasploit::ConnectionError.new('Connection refused') + ) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + expect(output.string).to include('Connection error') + end + + it 'handles RPC startup errors gracefully' do + app = described_class.new(['--config', config_file.path], output: output) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise( + Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found') + ) + allow(Signal).to receive(:trap) + + expect { app.run }.to raise_error(SystemExit) do |error| + expect(error.status).to eq(1) + end + end + + it 'includes ensure_rpc_server in the run sequence before initialize_metasploit_client' do + app = described_class.new(['--config', config_file.path], output: output) + + mock_rpc_manager = instance_double(Msf::MCP::RpcManager) + allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager) + allow(mock_rpc_manager).to receive(:ensure_rpc_available) + + allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:authenticate) + allow(Msf::MCP::Server).to receive(:new).and_return(mock_mcp_server) + allow(mock_mcp_server).to receive(:start) + allow(Signal).to receive(:trap) + + # Track the order of operations + order = [] + allow(mock_rpc_manager).to receive(:ensure_rpc_available) { order << :ensure_rpc } + allow(Msf::MCP::Metasploit::Client).to receive(:new) { order << :init_client; mock_client } + + app.run + + expect(order).to eq([:ensure_rpc, :init_client]) + end + end +end diff --git a/spec/lib/msf/core/mcp/config/loader_spec.rb b/spec/lib/msf/core/mcp/config/loader_spec.rb new file mode 100644 index 0000000000000..8f78a03819300 --- /dev/null +++ b/spec/lib/msf/core/mcp/config/loader_spec.rb @@ -0,0 +1,841 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'tempfile' + +RSpec.describe Msf::MCP::Config::Loader do + let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') } + + describe '.load' do + context 'with valid YAML file' do + let(:config_file) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') } + + it 'loads configuration from file' do + config = described_class.load(config_file) + + expect(config).to be_a(Hash) + expect(config[:msf_api][:type]).to eq('messagepack') + expect(config[:msf_api][:host]).to eq('localhost') + end + + it 'returns configuration with symbolized keys' do + config = described_class.load(config_file) + + expect(config.keys).to all(be_a(Symbol)) + end + end + + context 'with file not found' do + it 'raises ConfigurationError with descriptive message' do + expect { + described_class.load('/nonexistent/config.yaml') + }.to raise_error(Msf::MCP::Config::ConfigurationError, /not found/) + end + end + + context 'with invalid YAML syntax' do + let(:invalid_yaml_file) { Tempfile.new(['invalid', '.yaml']) } + + before do + invalid_yaml_file.write("invalid: yaml: content:\n - unbalanced") + invalid_yaml_file.flush + end + + after do + invalid_yaml_file.close + invalid_yaml_file.unlink + end + + it 'raises ConfigurationError with YAML error details' do + expect { + described_class.load(invalid_yaml_file.path) + }.to raise_error(Msf::MCP::Config::ConfigurationError, /Invalid YAML syntax/) + end + end + + context 'with non-hash YAML content' do + let(:array_yaml_file) { Tempfile.new(['array', '.yaml']) } + + before do + array_yaml_file.write("- item1\n- item2\n- item3") + array_yaml_file.flush + end + + after do + array_yaml_file.close + array_yaml_file.unlink + end + + it 'raises ConfigurationError requiring hash/dictionary' do + expect { + described_class.load(array_yaml_file.path) + }.to raise_error(Msf::MCP::Config::ConfigurationError, /must contain a YAML hash/) + end + end + end + + describe '.load_from_hash' do + context 'with minimal configuration' do + let(:config_hash) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'pass' + } + } + end + + it 'applies default values' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:port]).to eq(55553) + expect(config[:msf_api][:endpoint]).to eq('/api/') + expect(config[:mcp][:transport]).to eq('stdio') + end + + it 'applies rate limit defaults' do + config = described_class.load_from_hash(config_hash) + + expect(config[:rate_limit][:enabled]).to be true + expect(config[:rate_limit][:requests_per_minute]).to eq(60) + expect(config[:rate_limit][:burst_size]).to eq(10) + end + + it 'applies logging defaults' do + config = described_class.load_from_hash(config_hash) + + expect(config[:logging][:enabled]).to be false + expect(config[:logging][:level]).to eq('INFO') + end + end + + context 'with JSON-RPC configuration' do + let(:config_hash) do + { + msf_api: { + type: 'json-rpc', + host: 'localhost', + token: 'token123' + } + } + end + + it 'applies JSON-RPC default port' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:port]).to eq(8081) + end + + it 'applies JSON-RPC default endpoint' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:endpoint]).to eq('/api/v1/json-rpc') + end + end + + context 'with partial defaults override' do + let(:config_hash) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 9999, + user: 'msf', + password: 'pass' + }, + rate_limit: { requests_per_minute: 120 } + } + end + + it 'preserves explicit port setting' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:port]).to eq(9999) + end + + it 'merges rate limit defaults with provided values' do + config = described_class.load_from_hash(config_hash) + + expect(config[:rate_limit][:requests_per_minute]).to eq(120) + expect(config[:rate_limit][:enabled]).to be true + expect(config[:rate_limit][:burst_size]).to eq(10) + end + end + + context 'with disabled rate limiting' do + let(:config_hash) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'pass' + }, + rate_limit: { enabled: false } + } + end + + it 'respects disabled rate limiting' do + config = described_class.load_from_hash(config_hash) + + expect(config[:rate_limit][:enabled]).to be false + end + end + end + + describe 'default values' do + context 'with completely empty configuration' do + let(:config_hash) { {} } + + it 'creates all nested configuration hashes' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api]).to be_a(Hash) + expect(config[:mcp]).to be_a(Hash) + expect(config[:rate_limit]).to be_a(Hash) + expect(config[:logging]).to be_a(Hash) + end + end + + context 'MSF API defaults' do + let(:config_hash) { {} } + + it 'sets default type to messagepack' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:type]).to eq('messagepack') + end + + it 'sets default host to localhost' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:host]).to eq('localhost') + end + + it 'sets default port to 55553 for messagepack' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:port]).to eq(55553) + end + + it 'sets default ssl to true' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:ssl]).to be true + end + + it 'sets default endpoint to /api/ for messagepack' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:endpoint]).to eq('/api/') + end + + it 'sets default auto_start_rpc to true' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:auto_start_rpc]).to be true + end + + context 'when type is json-rpc' do + let(:config_hash) { { msf_api: { type: 'json-rpc' } } } + + it 'sets default port to 8081' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:port]).to eq(8081) + end + + it 'sets default endpoint to /api/v1/json-rpc' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:endpoint]).to eq('/api/v1/json-rpc') + end + end + + context 'when ssl is explicitly set to false' do + let(:config_hash) { { msf_api: { ssl: false } } } + + it 'preserves explicit false value' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:ssl]).to be false + end + end + + context 'when ssl is explicitly set to true' do + let(:config_hash) { { msf_api: { ssl: true } } } + + it 'preserves explicit true value' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:ssl]).to be true + end + end + + context 'when auto_start_rpc is explicitly set to false' do + let(:config_hash) { { msf_api: { auto_start_rpc: false } } } + + it 'preserves explicit false value' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:auto_start_rpc]).to be false + end + end + + context 'when auto_start_rpc is explicitly set to true' do + let(:config_hash) { { msf_api: { auto_start_rpc: true } } } + + it 'preserves explicit true value' do + config = described_class.load_from_hash(config_hash) + expect(config[:msf_api][:auto_start_rpc]).to be true + end + end + end + + context 'MCP defaults' do + let(:config_hash) { {} } + + it 'sets default transport to stdio' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:transport]).to eq('stdio') + end + + context 'with stdio transport' do + let(:config_hash) { { mcp: { transport: 'stdio' } } } + + it 'does not set host for stdio transport' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:host]).to be_nil + end + + it 'does not set port for stdio transport' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:port]).to be_nil + end + end + + context 'with http transport' do + let(:config_hash) { { mcp: { transport: 'http' } } } + + it 'sets default host to localhost' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:host]).to eq('localhost') + end + + it 'sets default port to 3000' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:port]).to eq(3000) + end + end + + context 'with http transport and explicit values' do + let(:config_hash) do + { + mcp: { + transport: 'http', + host: '0.0.0.0', + port: 8080 + } + } + end + + it 'preserves explicit host value' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:host]).to eq('0.0.0.0') + end + + it 'preserves explicit port value' do + config = described_class.load_from_hash(config_hash) + expect(config[:mcp][:port]).to eq(8080) + end + end + end + + context 'rate limit defaults' do + let(:config_hash) { {} } + + it 'sets default enabled to true' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:enabled]).to be true + end + + it 'sets default requests_per_minute to 60' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:requests_per_minute]).to eq(60) + end + + it 'sets default burst_size to 10' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:burst_size]).to eq(10) + end + + context 'when rate_limit is explicitly disabled' do + let(:config_hash) { { rate_limit: { enabled: false } } } + + it 'preserves explicit false value' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:enabled]).to be false + end + + it 'still applies other defaults' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:requests_per_minute]).to eq(60) + expect(config[:rate_limit][:burst_size]).to eq(10) + end + end + + context 'with partial rate_limit configuration' do + let(:config_hash) { { rate_limit: { requests_per_minute: 100 } } } + + it 'applies defaults for missing values' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:enabled]).to be true + expect(config[:rate_limit][:burst_size]).to eq(10) + end + + it 'preserves explicit values' do + config = described_class.load_from_hash(config_hash) + expect(config[:rate_limit][:requests_per_minute]).to eq(100) + end + end + end + + context 'logging defaults' do + let(:config_hash) { {} } + let(:default_log_file) { File.join(Msf::Config.log_directory, 'msfmcp.log') } + + it 'sets default enabled to false' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:enabled]).to be false + end + + it 'sets default level to INFO' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:level]).to eq('INFO') + end + + it 'sets default log_file to msfmcp.log in the default Msf log directory' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:log_file]).to eq(default_log_file) + end + + it 'sets default sanitize to true' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:sanitize]).to be true + end + + context 'when logging is explicitly enabled' do + let(:config_hash) { { logging: { enabled: true } } } + + it 'preserves explicit true value' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:enabled]).to be true + end + + it 'still applies other defaults' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:level]).to eq('INFO') + expect(config[:logging][:log_file]).to eq(default_log_file) + expect(config[:logging][:sanitize]).to be true + end + end + + context 'when sanitize is explicitly set to false' do + let(:config_hash) { { logging: { sanitize: false } } } + + it 'preserves explicit false value' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:sanitize]).to be false + end + end + + context 'when sanitize is explicitly set to true' do + let(:config_hash) { { logging: { sanitize: true } } } + + it 'preserves explicit true value' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:sanitize]).to be true + end + end + + context 'with partial logging configuration' do + let(:config_hash) { { logging: { level: 'DEBUG', log_file: 'custom.log' } } } + + it 'applies default for enabled' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:enabled]).to be false + end + + it 'applies default for sanitize' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:sanitize]).to be true + end + + it 'preserves explicit values' do + config = described_class.load_from_hash(config_hash) + expect(config[:logging][:level]).to eq('DEBUG') + expect(config[:logging][:log_file]).to eq('custom.log') + end + end + end + + context 'preserving existing values' do + let(:config_hash) do + { + msf_api: { + type: 'json-rpc', + host: 'remote.example.com', + port: 9000, + ssl: false, + endpoint: '/custom/api/', + token: 'custom_token' + }, + mcp: { + transport: 'http', + host: '192.168.1.100', + port: 5000 + }, + rate_limit: { + enabled: false, + requests_per_minute: 120, + burst_size: 20 + }, + logging: { + enabled: true, + level: 'DEBUG', + log_file: 'debug.log' + } + } + end + + it 'does not override any explicitly set values' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:type]).to eq('json-rpc') + expect(config[:msf_api][:host]).to eq('remote.example.com') + expect(config[:msf_api][:port]).to eq(9000) + expect(config[:msf_api][:ssl]).to be false + expect(config[:msf_api][:endpoint]).to eq('/custom/api/') + expect(config[:msf_api][:token]).to eq('custom_token') + + expect(config[:mcp][:transport]).to eq('http') + expect(config[:mcp][:host]).to eq('192.168.1.100') + expect(config[:mcp][:port]).to eq(5000) + + expect(config[:rate_limit][:enabled]).to be false + expect(config[:rate_limit][:requests_per_minute]).to eq(120) + expect(config[:rate_limit][:burst_size]).to eq(20) + + expect(config[:logging][:enabled]).to be true + expect(config[:logging][:level]).to eq('DEBUG') + expect(config[:logging][:log_file]).to eq('debug.log') + end + end + end + + describe 'environment variable overrides' do + let(:config_file) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') } + let(:env_vars) do + %w[ + MSF_API_TYPE MSF_API_HOST MSF_API_PORT MSF_API_SSL MSF_API_ENDPOINT + MSF_API_USER MSF_API_PASSWORD MSF_API_TOKEN MSF_AUTO_START_RPC + MSF_MCP_TRANSPORT MSF_MCP_HOST MSF_MCP_PORT + ] + end + + before do + env_vars.each { |var| ENV.delete(var) } + end + + after do + env_vars.each { |var| ENV.delete(var) } + end + + context 'MSF API configuration overrides' do + context 'when MSF_API_TYPE is set' do + before { ENV['MSF_API_TYPE'] = 'json-rpc' } + + it 'overrides the type value' do + config = described_class.load(config_file) + expect(config[:msf_api][:type]).to eq('json-rpc') + end + end + + context 'when MSF_API_HOST is set' do + before { ENV['MSF_API_HOST'] = 'override.example.com' } + + it 'overrides the host value' do + config = described_class.load(config_file) + expect(config[:msf_api][:host]).to eq('override.example.com') + end + end + + context 'when MSF_API_PORT is set' do + before { ENV['MSF_API_PORT'] = '9999' } + + it 'overrides the port value as integer' do + config = described_class.load(config_file) + expect(config[:msf_api][:port]).to eq(9999) + end + end + + context 'when MSF_API_SSL is set' do + let(:ssl_false_config) do + { msf_api: { type: 'messagepack', host: 'localhost', ssl: false } } + end + + context "to '0'" do + before { ENV['MSF_API_SSL'] = '0' } + + it 'overrides SSL to false' do + config = described_class.load(config_file) + expect(config[:msf_api][:ssl]).to be false + end + end + + context "to 'false'" do + before { ENV['MSF_API_SSL'] = 'false' } + + it 'overrides SSL to false' do + config = described_class.load(config_file) + expect(config[:msf_api][:ssl]).to be false + end + end + + context "to '1'" do + before { ENV['MSF_API_SSL'] = '1' } + + it 'overrides SSL to true' do + config = described_class.load_from_hash(ssl_false_config) + expect(config[:msf_api][:ssl]).to be true + end + end + + context "to 'true'" do + before { ENV['MSF_API_SSL'] = 'true' } + + it 'overrides SSL to true' do + config = described_class.load_from_hash(ssl_false_config) + expect(config[:msf_api][:ssl]).to be true + end + end + + context "to 'yes'" do + before { ENV['MSF_API_SSL'] = 'yes' } + + it 'overrides SSL to true' do + config = described_class.load_from_hash(ssl_false_config) + expect(config[:msf_api][:ssl]).to be true + end + end + + context "to empty string" do + before { ENV['MSF_API_SSL'] = '' } + + it 'does not override SSL' do + config = described_class.load(config_file) + expect(config[:msf_api][:ssl]).to be true + end + end + end + + context 'when MSF_API_ENDPOINT is set' do + before { ENV['MSF_API_ENDPOINT'] = '/custom/api/v2/' } + + it 'overrides the endpoint value' do + config = described_class.load(config_file) + expect(config[:msf_api][:endpoint]).to eq('/custom/api/v2/') + end + end + + context 'when MSF_API_USER is set' do + before { ENV['MSF_API_USER'] = 'env_user' } + + it 'overrides the user value' do + config = described_class.load(config_file) + expect(config[:msf_api][:user]).to eq('env_user') + end + end + + context 'when MSF_API_PASSWORD is set' do + before { ENV['MSF_API_PASSWORD'] = 'env_password' } + + it 'overrides the password value' do + config = described_class.load(config_file) + expect(config[:msf_api][:password]).to eq('env_password') + end + end + + context 'when MSF_API_TOKEN is set' do + before { ENV['MSF_API_TOKEN'] = 'env_token_123' } + + it 'overrides the token value' do + config = described_class.load(config_file) + expect(config[:msf_api][:token]).to eq('env_token_123') + end + end + + context 'when MSF_AUTO_START_RPC is set' do + context "to 'false'" do + before { ENV['MSF_AUTO_START_RPC'] = 'false' } + + it 'overrides auto_start_rpc to false' do + config = described_class.load(config_file) + expect(config[:msf_api][:auto_start_rpc]).to be false + end + end + + context "to '0'" do + before { ENV['MSF_AUTO_START_RPC'] = '0' } + + it 'overrides auto_start_rpc to false' do + config = described_class.load(config_file) + expect(config[:msf_api][:auto_start_rpc]).to be false + end + end + + context "to 'true'" do + before { ENV['MSF_AUTO_START_RPC'] = 'true' } + + it 'overrides auto_start_rpc to true' do + config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } }) + expect(config[:msf_api][:auto_start_rpc]).to be true + end + end + + context "to '1'" do + before { ENV['MSF_AUTO_START_RPC'] = '1' } + + it 'overrides auto_start_rpc to true' do + config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } }) + expect(config[:msf_api][:auto_start_rpc]).to be true + end + end + + context "to 'yes'" do + before { ENV['MSF_AUTO_START_RPC'] = 'yes' } + + it 'overrides auto_start_rpc to true' do + config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } }) + expect(config[:msf_api][:auto_start_rpc]).to be true + end + end + end + end + + context 'MCP configuration overrides' do + context 'when MSF_MCP_TRANSPORT is set' do + before { ENV['MSF_MCP_TRANSPORT'] = 'http' } + + it 'overrides the transport value' do + config = described_class.load(config_file) + expect(config[:mcp][:transport]).to eq('http') + end + end + + context 'when MSF_MCP_HOST is set' do + before { ENV['MSF_MCP_HOST'] = '0.0.0.0' } + + it 'overrides the MCP host value' do + config = described_class.load(config_file) + expect(config[:mcp][:host]).to eq('0.0.0.0') + end + end + + context 'when MSF_MCP_PORT is set' do + before { ENV['MSF_MCP_PORT'] = '8080' } + + it 'overrides the MCP port value as integer' do + config = described_class.load(config_file) + expect(config[:mcp][:port]).to eq(8080) + end + end + end + + context 'with multiple ENV vars set' do + before do + ENV['MSF_API_TYPE'] = 'json-rpc' + ENV['MSF_API_HOST'] = 'multi.example.com' + ENV['MSF_API_PORT'] = '7777' + ENV['MSF_API_USER'] = 'multi_user' + ENV['MSF_API_PASSWORD'] = 'multi_pass' + ENV['MSF_API_TOKEN'] = 'multi_token' + ENV['MSF_MCP_TRANSPORT'] = 'http' + ENV['MSF_MCP_HOST'] = '127.0.0.1' + ENV['MSF_MCP_PORT'] = '3000' + end + + it 'overrides all specified values simultaneously' do + config = described_class.load(config_file) + + expect(config[:msf_api][:type]).to eq('json-rpc') + expect(config[:msf_api][:host]).to eq('multi.example.com') + expect(config[:msf_api][:port]).to eq(7777) + expect(config[:msf_api][:user]).to eq('multi_user') + expect(config[:msf_api][:password]).to eq('multi_pass') + expect(config[:msf_api][:token]).to eq('multi_token') + expect(config[:mcp][:transport]).to eq('http') + expect(config[:mcp][:host]).to eq('127.0.0.1') + expect(config[:mcp][:port]).to eq(3000) + end + end + + context 'with partial ENV overrides' do + before do + ENV['MSF_API_HOST'] = 'partial.example.com' + ENV['MSF_MCP_TRANSPORT'] = 'http' + end + + it 'overrides only specified values while keeping others' do + config = described_class.load(config_file) + + expect(config[:msf_api][:host]).to eq('partial.example.com') + expect(config[:mcp][:transport]).to eq('http') + # Other values should remain from file or defaults + expect(config[:msf_api][:port]).to be_a(Integer) + expect(config[:msf_api][:endpoint]).to eq('/api/') + end + end + + context 'when ENV vars are empty strings' do + before do + ENV['MSF_API_HOST'] = '' + ENV['MSF_API_PORT'] = '' + end + + it 'overrides with empty strings (current behavior)' do + config = described_class.load(config_file) + + # Current implementation: empty strings DO override values + # This tests the actual behavior, though it might not be ideal + expect(config[:msf_api][:host]).to eq('') + expect(config[:msf_api][:port]).to eq(0) # Empty string converts to 0 + end + end + + context 'using load_from_hash with ENV overrides' do + let(:config_hash) do + { + msf_api: { + type: 'messagepack', + host: 'hash.example.com', + port: 5555, + user: 'hash_user', + password: 'hash_pass' + }, + mcp: { + transport: 'stdio' + } + } + end + + before do + ENV['MSF_API_TYPE'] = 'json-rpc' + ENV['MSF_API_HOST'] = 'env.example.com' + ENV['MSF_API_PORT'] = '6666' + ENV['MSF_MCP_TRANSPORT'] = 'http' + end + + it 'applies ENV overrides on top of hash configuration' do + config = described_class.load_from_hash(config_hash) + + expect(config[:msf_api][:type]).to eq('json-rpc') + expect(config[:msf_api][:host]).to eq('env.example.com') + expect(config[:msf_api][:port]).to eq(6666) + expect(config[:msf_api][:user]).to eq('hash_user') # Not overridden + expect(config[:mcp][:transport]).to eq('http') + end + 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 new file mode 100644 index 0000000000000..4621f8c977f2f --- /dev/null +++ b/spec/lib/msf/core/mcp/config/validator_spec.rb @@ -0,0 +1,1030 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Config::Validator do + describe '.validate!' do + context 'with valid messagepack configuration' do + let(:valid_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + user: 'msf', + password: 'password' + } + } + end + + it 'returns true for valid configuration' do + expect(described_class.validate!(valid_config)).to be true + end + + it 'does not raise error' do + expect { described_class.validate!(valid_config) }.not_to raise_error + end + end + + context 'with valid json-rpc configuration' do + let(:valid_config) do + { + msf_api: { + type: 'json-rpc', + host: 'localhost', + port: 8081, + token: 'secret_token_123' + } + } + end + + it 'returns true for valid configuration' do + expect(described_class.validate!(valid_config)).to be true + end + end + + context 'with missing optional fields' do + it 'does not raise error for missing msf_api.type (has default)' do + config = { msf_api: { host: 'localhost', user: 'msf', password: 'pass' } } + + expect { + described_class.validate!(config) + }.not_to raise_error + end + + it 'does not raise error for missing msf_api.host (has default)' do + config = { msf_api: { type: 'messagepack', user: 'msf', password: 'pass' } } + + expect { + described_class.validate!(config) + }.not_to raise_error + end + + it 'raises ValidationError for empty msf_api.type' do + config = { + msf_api: { + type: '', + host: 'localhost', + user: 'msf', + password: 'pass' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.type']).to eq("must be one of the valid API types: messagepack, json-rpc") + end + end + + it 'raises ValidationError for whitespace-only msf_api.host' do + config = { + msf_api: { + type: 'messagepack', + host: ' ', + user: 'msf', + password: 'pass' + } + } + + # Whitespace host is not validated as "required", loader will apply default + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.host']).to eq("must be a non-empty string") + end + end + + it 'does not raise error for missing type and host (have defaults)' do + config = { msf_api: { user: 'msf', password: 'pass' } } + + expect { + described_class.validate!(config) + }.not_to raise_error + end + end + + context 'with invalid enum values' do + it 'raises ValidationError for invalid msf_api.type' do + config = { + msf_api: { + type: 'soap', + host: 'localhost' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.type']).to eq("must be one of the valid API types: messagepack, json-rpc") + end + end + + it 'raises ValidationError for invalid mcp_transport' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + }, + mcp: { + transport: 'websocket' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'mcp.transport']).to eq("must be one of the valid transport: stdio, http") + end + end + + it 'allows valid mcp_transport http' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + }, + mcp: { + transport: 'http' + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'allows valid mcp_transport stdio' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + }, + mcp: { + transport: 'stdio' + } + } + + expect(described_class.validate!(config)).to be true + end + end + + context 'with port validation' do + it 'accepts port 8080' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 8080, + user: 'msf', + password: 'password' + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'accepts port 1' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 1, + user: 'msf', + password: 'password' + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'accepts port 65535' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 65535, + user: 'msf', + password: 'password' + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'rejects port 0' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 0, + user: 'msf', + password: 'password' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535') + end + end + + it 'rejects negative port' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: -1, + user: 'msf', + password: 'password' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535') + end + end + + it 'rejects port 65536' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 65536, + user: 'msf', + password: 'password' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535') + end + end + end + + context 'with auto_start_rpc validation' do + it 'accepts auto_start_rpc set to true' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password', + auto_start_rpc: true + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'accepts auto_start_rpc set to false' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password', + auto_start_rpc: false + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'raises ValidationError for non-boolean auto_start_rpc' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password', + auto_start_rpc: 'yes' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.auto_start_rpc']).to eq('must be boolean (true or false)') + end + end + + it 'raises ValidationError for integer auto_start_rpc' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password', + auto_start_rpc: 1 + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.auto_start_rpc']).to eq('must be boolean (true or false)') + end + end + + it 'does not raise error when auto_start_rpc is not present' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + } + } + + expect { described_class.validate!(config) }.not_to raise_error + end + end + + context 'with conditional messagepack authentication requirements' do + it 'raises ValidationError for missing msf_api.user when auto-start cannot generate credentials' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + password: 'password', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable') + end + end + + it 'raises ValidationError for empty msf_api.user' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: '', + password: 'password', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable') + end + end + + it 'raises ValidationError for missing msf_api.password when auto-start cannot generate credentials' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable') + end + end + + it 'raises ValidationError for whitespace-only msf_api.password' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: ' ', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable') + end + end + + it 'raises ValidationError for both missing user and password when auto-start is disabled' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable') + expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable') + end + end + + it 'raises ValidationError for both missing user and password on remote host' do + config = { + msf_api: { + type: 'messagepack', + host: '192.0.2.1', + auto_start_rpc: true + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.user']).to be_a(String) + expect(error.errors[:'msf_api.password']).to be_a(String) + end + end + + it 'raises ValidationError for only user provided (partial credentials)' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + auto_start_rpc: true + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.password']).to be_a(String) + end + end + + it 'raises ValidationError for only password provided (partial credentials)' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + password: 'pass', + auto_start_rpc: true + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.user']).to be_a(String) + end + end + end + + context 'with optional credentials when auto-start can generate them' do + it 'allows missing credentials on localhost with auto_start_rpc enabled' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + auto_start_rpc: true + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'allows missing credentials on 127.0.0.1 with auto_start_rpc enabled' do + config = { + msf_api: { + type: 'messagepack', + host: '127.0.0.1', + auto_start_rpc: true + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'allows missing credentials on ::1 with auto_start_rpc enabled' do + config = { + msf_api: { + type: 'messagepack', + host: '::1', + auto_start_rpc: true + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'allows missing credentials when auto_start_rpc key is absent (defaults to true)' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost' + } + } + + expect(described_class.validate!(config)).to be true + end + + it 'does not allow missing credentials on remote host even with auto_start_rpc true' do + config = { + msf_api: { + type: 'messagepack', + host: '192.0.2.1', + auto_start_rpc: true + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) + end + + it 'does not allow missing credentials when auto_start_rpc is false' do + config = { + msf_api: { + type: 'messagepack', + host: 'localhost', + auto_start_rpc: false + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) + end + end + + context 'with conditional json-rpc authentication requirements' do + it 'raises ValidationError for missing msf_api.token' do + config = { + msf_api: { + type: 'json-rpc', + host: 'localhost' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication') + end + end + + it 'raises ValidationError for empty msf_api.token' do + config = { + msf_api: { + type: 'json-rpc', + host: 'localhost', + token: '' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication') + end + end + + it 'raises ValidationError for whitespace-only msf_api.token' do + config = { + msf_api: { + type: 'json-rpc', + host: 'localhost', + token: ' ' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication') + end + end + + it 'accepts valid token' do + config = { + msf_api: { + type: 'json-rpc', + host: 'localhost', + token: 'valid_token_123' + } + } + + expect(described_class.validate!(config)).to be true + end + end + + context 'with multiple validation errors' do + it 'collects all validation errors' do + config = { + msf_api: { + type: 'invalid_type', + port: 0, + user: 'msf', + password: 'pass' + }, + mcp: { + transport: 'invalid' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors.keys).to include(:'msf_api.type', :'msf_api.port', :'mcp.transport') + expect(error.errors.size).to be >= 3 + end + end + + it 'includes all errors in message' do + config = { + msf_api: { + type: 'messagepack', + port: 70000, + user: '', + password: '' + } + } + + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.message).to include('msf_api.port') + expect(error.message).to include('msf_api.user') + expect(error.message).to include('msf_api.password') + end + end + end + context 'with rate_limit validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'pass' + } + } + end + + it 'accepts valid rate_limit configuration' do + config = base_config.merge(rate_limit: { enabled: true, requests_per_minute: 60, burst_size: 10 }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts rate_limit with only enabled' do + config = base_config.merge(rate_limit: { enabled: false }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts rate_limit with no keys (all optional)' do + config = base_config.merge(rate_limit: {}) + expect(described_class.validate!(config)).to be true + end + + it 'does not validate rate_limit when section is absent' do + expect(described_class.validate!(base_config)).to be true + end + + it 'raises ValidationError when rate_limit is not a Hash' do + config = base_config.merge(rate_limit: 'yes') + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:rate_limit]).to eq('must be a configuration hash') + end + end + + it 'raises ValidationError for non-boolean enabled' do + config = base_config.merge(rate_limit: { enabled: 'yes' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.enabled']).to eq('must be boolean (true or false)') + end + end + + it 'raises ValidationError for requests_per_minute of 0' do + config = base_config.merge(rate_limit: { requests_per_minute: 0 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1') + end + end + + it 'raises ValidationError for negative requests_per_minute' do + config = base_config.merge(rate_limit: { requests_per_minute: -5 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1') + end + end + + it 'raises ValidationError for non-integer requests_per_minute' do + config = base_config.merge(rate_limit: { requests_per_minute: 'fast' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1') + end + end + + it 'raises ValidationError for float requests_per_minute' do + config = base_config.merge(rate_limit: { requests_per_minute: 1.5 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1') + end + end + + it 'accepts requests_per_minute of 1' do + config = base_config.merge(rate_limit: { requests_per_minute: 1 }) + expect(described_class.validate!(config)).to be true + end + + it 'raises ValidationError for burst_size of 0' do + config = base_config.merge(rate_limit: { burst_size: 0 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1') + end + end + + it 'raises ValidationError for negative burst_size' do + config = base_config.merge(rate_limit: { burst_size: -1 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1') + end + end + + it 'raises ValidationError for non-integer burst_size' do + config = base_config.merge(rate_limit: { burst_size: 'large' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1') + end + end + + it 'accepts burst_size of 1' do + config = base_config.merge(rate_limit: { burst_size: 1 }) + expect(described_class.validate!(config)).to be true + end + + it 'collects multiple rate_limit errors at once' do + config = base_config.merge(rate_limit: { enabled: 'yes', requests_per_minute: 0, burst_size: -1 }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors.keys).to include(:'rate_limit.enabled', :'rate_limit.requests_per_minute', :'rate_limit.burst_size') + end + end + end + context 'with mcp section type validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'pass' + } + } + end + + it 'raises ValidationError when mcp is not a Hash' do + config = base_config.merge(mcp: 'stdio') + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:mcp]).to eq('must be a configuration hash') + end + end + + it 'raises ValidationError when mcp is an integer' do + config = base_config.merge(mcp: 42) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:mcp]).to eq('must be a configuration hash') + end + end + + it 'does not raise when mcp is absent' do + expect(described_class.validate!(base_config)).to be true + end + end + + context 'with logging validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'pass' + } + } + end + + it 'accepts valid logging configuration' do + config = base_config.merge(logging: { enabled: true, level: 'INFO', log_file: 'msfmcp.log' }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts logging with no keys' do + config = base_config.merge(logging: {}) + expect(described_class.validate!(config)).to be true + end + + it 'does not validate logging when section is absent' do + expect(described_class.validate!(base_config)).to be true + end + + it 'raises ValidationError when logging is not a Hash' do + config = base_config.merge(logging: true) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:logging]).to eq('must be a configuration hash') + end + end + + it 'raises ValidationError for non-boolean logging.enabled' do + config = base_config.merge(logging: { enabled: 'yes' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'logging.enabled']).to eq('must be boolean (true or false)') + end + end + + it 'raises ValidationError for invalid logging.level' do + config = base_config.merge(logging: { level: 'VERBOSE' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'logging.level']).to include('must be one of') + end + end + + it 'accepts all valid log levels' do + %w[DEBUG INFO WARN ERROR].each do |level| + config = base_config.merge(logging: { level: level }) + expect(described_class.validate!(config)).to be true + end + end + + it 'accepts case-insensitive log levels' do + config = base_config.merge(logging: { level: 'info' }) + expect(described_class.validate!(config)).to be true + end + + it 'raises ValidationError for empty logging.log_file' do + config = base_config.merge(logging: { log_file: '' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'logging.log_file']).to eq('must be a non-empty string') + end + end + + it 'raises ValidationError for whitespace-only logging.log_file' do + config = base_config.merge(logging: { log_file: ' ' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'logging.log_file']).to eq('must be a non-empty string') + end + end + + it 'accepts sanitize set to true' do + config = base_config.merge(logging: { sanitize: true }) + expect(described_class.validate!(config)).to be true + end + + it 'accepts sanitize set to false' do + config = base_config.merge(logging: { sanitize: false }) + expect(described_class.validate!(config)).to be true + end + + it 'raises ValidationError for non-boolean logging.sanitize' do + config = base_config.merge(logging: { sanitize: 'yes' }) + expect { + described_class.validate!(config) + }.to raise_error(Msf::MCP::Config::ValidationError) do |error| + expect(error.errors[:'logging.sanitize']).to eq('must be boolean (true or false)') + end + end + + it 'does not validate sanitize when key is absent' do + config = base_config.merge(logging: { enabled: true, level: 'INFO' }) + expect(described_class.validate!(config)).to be true + end + end + end + + describe '#credentials_can_be_generated?' do + subject(:validator) { described_class.new } + + it 'returns true for localhost with auto_start_rpc true' do + config = { msf_api: { host: 'localhost', auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be true + end + + it 'returns true for 127.0.0.1 with auto_start_rpc true' do + config = { msf_api: { host: '127.0.0.1', auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be true + end + + it 'returns true for ::1 with auto_start_rpc true' do + config = { msf_api: { host: '::1', auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be true + end + + it 'returns true when auto_start_rpc key is absent (not explicitly false)' do + config = { msf_api: { host: 'localhost' } } + expect(validator.send(:credentials_can_be_generated?, config)).to be true + end + + it 'returns false when auto_start_rpc is false' do + config = { msf_api: { host: 'localhost', auto_start_rpc: false } } + expect(validator.send(:credentials_can_be_generated?, config)).to be false + end + + it 'returns false for remote host' do + config = { msf_api: { host: '192.0.2.1', auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be false + end + + it 'returns false for remote hostname' do + config = { msf_api: { host: 'remote.example.com', auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be false + end + + it 'returns false when host is nil' do + config = { msf_api: { auto_start_rpc: true } } + expect(validator.send(:credentials_can_be_generated?, config)).to be false + end + end + + describe Msf::MCP::Config::ValidationError do + describe '#message' do + it 'has default message for empty errors' do + error = described_class.new({}) + expect(error.message).to eq('Configuration validation failed') + end + + it 'includes field names and error descriptions' do + errors = { + :'msf_api.host' => 'is required', + :'msf_api.port' => 'must be between 1 and 65535' + } + error = described_class.new(errors) + + expect(error.message).to include('msf_api.host is required') + expect(error.message).to include('msf_api.port must be between 1 and 65535') + end + + it 'formats multiple errors with bullets' do + errors = { + :'msf_api.type' => 'is required', + :'msf_api.host' => 'is required' + } + error = described_class.new(errors) + + expect(error.message).to include('Configuration validation failed:') + expect(error.message).to include(' - ') + end + end + + describe '#errors' do + it 'provides access to errors hash' do + errors = { :'msf_api.host' => 'is required' } + error = described_class.new(errors) + + expect(error.errors).to eq(errors) + end + end + end +end diff --git a/spec/lib/msf/core/mcp/errors_spec.rb b/spec/lib/msf/core/mcp/errors_spec.rb new file mode 100644 index 0000000000000..b9a8aef66644b --- /dev/null +++ b/spec/lib/msf/core/mcp/errors_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Error do + describe 'inheritance' do + it 'inherits from StandardError' do + expect(described_class).to be < StandardError + end + + it 'can be rescued as StandardError' do + expect do + raise described_class, 'test' + end.to raise_error(StandardError) + end + end +end + +RSpec.describe Msf::MCP::Config::ConfigurationError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end + +RSpec.describe Msf::MCP::Config::ValidationError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end + + describe '#initialize' do + it 'stores validation errors' do + errors = { 'msf_api.type' => 'must be one of the valid API types: messagepack, json-rpc' } + exception = described_class.new(errors) + expect(exception.errors).to eq(errors) + end + + it 'returns a generic message if no errors have been stored' do + exception = described_class.new + expect(exception.message).to eq("Configuration validation failed") + end + + it 'formats error messages correctly' do + errors = { + 'msf_api.type' => 'must be one of the valid API types: messagepack, json-rpc', + 'msf_api.host' => 'must be a non-empty string' + } + exception = described_class.new(errors) + expected_message = <<~MSG.chomp + Configuration validation failed: + - msf_api.type must be one of the valid API types: messagepack, json-rpc + - msf_api.host must be a non-empty string + MSG + expect(exception.message).to eq(expected_message) + end + end +end + +RSpec.describe Msf::MCP::Security::ValidationError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end + +RSpec.describe Msf::MCP::Security::RateLimitExceededError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end + + describe '#initialize' do + it 'stores retry_after value' do + exception = described_class.new(32) + expect(exception.retry_after).to eq(32) + end + + it 'formats message correctly' do + exception = described_class.new(32) + expect(exception.message).to eq("Rate limit exceeded. Retry after 32 seconds.") + end + end +end + +RSpec.describe Msf::MCP::Metasploit::AuthenticationError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end + +RSpec.describe Msf::MCP::Metasploit::ConnectionError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end + +RSpec.describe Msf::MCP::Metasploit::APIError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end + +RSpec.describe Msf::MCP::Metasploit::RpcStartupError do + describe 'inheritance' do + it 'inherits from Msf::MCP::Error' do + expect(described_class).to be < Msf::MCP::Error + end + end +end diff --git a/spec/lib/msf/core/mcp/logging/log_constants_spec.rb b/spec/lib/msf/core/mcp/logging/log_constants_spec.rb new file mode 100644 index 0000000000000..a0e163cafa7f2 --- /dev/null +++ b/spec/lib/msf/core/mcp/logging/log_constants_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'tempfile' +require 'json' + +RSpec.describe 'Msf::MCP log constants' do + describe 'LOG_SOURCE' do + it 'is set to mcp' do + expect(Msf::MCP::LOG_SOURCE).to eq('mcp') + end + end + + describe 'LOG_LEVEL constants' do + it 'maps LOG_DEBUG to LEV_3' do + expect(Msf::MCP::LOG_DEBUG).to eq(Rex::Logging::LEV_3) + end + + it 'maps LOG_INFO to LEV_2' do + expect(Msf::MCP::LOG_INFO).to eq(Rex::Logging::LEV_2) + end + + it 'maps LOG_WARN to LEV_1' do + expect(Msf::MCP::LOG_WARN).to eq(Rex::Logging::LEV_1) + end + + it 'maps LOG_ERROR to LEV_0' do + expect(Msf::MCP::LOG_ERROR).to eq(Rex::Logging::LEV_0) + end + end + + describe 'explicit source and level usage' do + let(:log_file) { Tempfile.new(['log_constants_test', '.log']).tap(&:close).path } + let(:log_source) { Msf::MCP::LOG_SOURCE } + + before do + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_3) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + File.delete(log_file) if File.exist?(log_file) + end + + def last_log_entry + JSON.parse(File.read(log_file).strip.split("\n").last) + end + + it 'routes messages to the mcp source when passed explicitly' do + ilog('info message', log_source, Msf::MCP::LOG_INFO) + expect(last_log_entry['message']).to include('info message') + expect(last_log_entry['severity']).to eq('INFO') + end + + it 'does not affect the default core source' do + ilog('core message') + expect(File.read(log_file)).to be_empty + end + + it 'filters messages below the registered threshold' do + deregister_log_source(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_1) + + dlog('should be filtered', log_source, Msf::MCP::LOG_DEBUG) + expect(File.read(log_file)).to be_empty + + wlog('should appear', log_source, Msf::MCP::LOG_WARN) + expect(File.read(log_file)).not_to be_empty + end + end +end diff --git a/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb new file mode 100644 index 0000000000000..62f9720af39f1 --- /dev/null +++ b/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'tempfile' +require 'json' + +RSpec.describe Msf::MCP::Logging::Sinks::JsonFlatfile do + let(:log_path) { Tempfile.new(['json_flatfile_test', '.log']).tap(&:close).path } + let(:sink) { described_class.new(log_path) } + + after do + sink.cleanup rescue nil + File.delete(log_path) if File.exist?(log_path) + end + + describe '#initialize' do + it 'creates the log file' do + described_class.new(log_path) + expect(File.exist?(log_path)).to be true + end + + it 'opens the file in append mode' do + # Write some content first + File.write(log_path, "existing\n") + new_sink = described_class.new(log_path) + new_sink.log(:info, 'mcp', 0, 'appended') + new_sink.cleanup + + content = File.read(log_path) + expect(content).to start_with("existing\n") + expect(content).to include('appended') + end + + it 'inherits from JsonStream' do + expect(described_class.superclass).to eq(Msf::MCP::Logging::Sinks::JsonStream) + end + end + + describe '#log' do + it 'writes a JSON line to the file' do + sink.log(:info, 'mcp', 0, 'test message') + content = File.read(log_path) + expect(content).not_to be_empty + + entry = JSON.parse(content.strip) + expect(entry).to be_a(Hash) + end + + it 'includes timestamp, severity, level, source, and message' do + sink.log(:error, 'mcp', 2, 'something broke') + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['timestamp']).not_to be_nil + expect(entry['severity']).to eq('ERROR') + expect(entry['level']).to eq('2') + expect(entry['source']).to eq('mcp') + expect(entry['message']).to include('something broke') + end + + it 'writes one JSON object per line' do + sink.log(:info, 'mcp', 0, 'first') + sink.log(:info, 'mcp', 0, 'second') + + lines = File.read(log_path).strip.split("\n") + expect(lines.length).to eq(2) + expect(JSON.parse(lines[0])['message']).to include('first') + expect(JSON.parse(lines[1])['message']).to include('second') + end + + context 'with a Hash message' do + it 'extracts :message from the hash' do + sink.log(:info, 'mcp', 0, { message: 'structured log' }) + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['message']).to eq('structured log') + end + + it 'includes :context when present' do + sink.log(:info, 'mcp', 0, { message: 'with context', context: { tool: 'search' } }) + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['context']).to be_a(Hash) + expect(entry['context']['tool']).to eq('search') + end + + it 'omits :context when empty' do + sink.log(:info, 'mcp', 0, { message: 'no context', context: {} }) + entry = JSON.parse(File.read(log_path).strip) + + expect(entry).not_to have_key('context') + end + + it 'formats Exception in :exception as structured hash' do + error = StandardError.new('test error') + error.set_backtrace(['line1', 'line2']) + + sink.log(:error, 'mcp', 0, { message: 'error occurred', exception: error }) + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['exception']).to be_a(Hash) + expect(entry['exception']['class']).to eq('StandardError') + expect(entry['exception']['message']).to eq('test error') + end + + it 'passes non-Exception :exception values through as-is' do + sink.log(:error, 'mcp', 0, { message: 'error', exception: 'just a string' }) + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['exception']).to eq('just a string') + end + end + + context 'with a String message' do + it 'uses the string directly as message' do + sink.log(:info, 'mcp', 0, 'plain string') + entry = JSON.parse(File.read(log_path).strip) + + expect(entry['message']).to eq('plain string') + expect(entry).not_to have_key('context') + expect(entry).not_to have_key('exception') + end + end + end + + describe '#cleanup' do + it 'closes the underlying file' do + sink.log(:info, 'mcp', 0, 'before cleanup') + sink.cleanup + + # Writing after cleanup should raise (file is closed) + expect { sink.log(:info, 'mcp', 0, 'after cleanup') }.to raise_error(IOError) + end + end +end diff --git a/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb new file mode 100644 index 0000000000000..547502bdf99ef --- /dev/null +++ b/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'stringio' +require 'json' + +RSpec.describe Msf::MCP::Logging::Sinks::JsonStream do + let(:stream) { StringIO.new } + let(:sink) { described_class.new(stream) } + let(:log_source) { Msf::MCP::LOG_SOURCE } + + # Helper: parse the last JSON line written to the stream + def last_entry + stream.rewind + lines = stream.read.strip.split("\n") + JSON.parse(lines.last) + end + + describe '#initialize' do + it 'accepts any IO-like stream' do + expect { described_class.new(StringIO.new) }.not_to raise_error + end + + it 'includes Rex::Logging::LogSink' do + expect(described_class.ancestors).to include(Rex::Logging::LogSink) + end + end + + describe '#log' do + it 'writes JSON to the stream' do + sink.log(:info, 'mcp', 0, 'test') + expect(last_entry).to be_a(Hash) + end + + it 'flushes after each write' do + expect(stream).to receive(:flush).once + sink.log(:info, 'mcp', 0, 'test') + end + + it 'appends a newline after each entry' do + sink.log(:info, 'mcp', 0, 'test') + stream.rewind + expect(stream.read).to end_with("\n") + end + + it 'includes a timestamp' do + sink.log(:info, 'mcp', 0, 'test') + expect(last_entry['timestamp']).not_to be_nil + end + + it 'uppercases the severity' do + sink.log(:error, 'mcp', 0, 'test') + expect(last_entry['severity']).to eq('ERROR') + end + + it 'converts level to string' do + sink.log(:info, 'mcp', 2, 'test') + expect(last_entry['level']).to eq('2') + end + + it 'converts source to string' do + sink.log(:info, :mcp, 0, 'test') + expect(last_entry['source']).to eq('mcp') + end + + context 'with a String message' do + it 'uses the string as message' do + sink.log(:info, 'mcp', 0, 'plain text') + expect(last_entry['message']).to eq('plain text') + end + + it 'does not include context or exception keys' do + sink.log(:info, 'mcp', 0, 'plain text') + expect(last_entry).not_to have_key('context') + expect(last_entry).not_to have_key('exception') + end + end + + context 'with a Hash message' do + it 'extracts :message from the hash' do + sink.log(:info, 'mcp', 0, { message: 'structured' }) + expect(last_entry['message']).to eq('structured') + end + + it 'falls back to hash.to_s when :message is nil' do + sink.log(:info, 'mcp', 0, { context: { a: 1 } }) + # message is the Hash#to_s representation since :message key is absent + expect(last_entry['message']).to include('context') + end + + it 'does not overwrite message when :message is empty string' do + sink.log(:info, 'mcp', 0, { message: '', context: { a: 1 } }) + # Empty :message is skipped, so message stays as hash.to_s + expect(last_entry['message']).to include('context') + end + + it 'includes :context when present and non-empty' do + sink.log(:info, 'mcp', 0, { message: 'test', context: { tool: 'search' } }) + expect(last_entry['context']).to eq({ 'tool' => 'search' }) + end + + it 'omits :context when nil' do + sink.log(:info, 'mcp', 0, { message: 'test', context: nil }) + expect(last_entry).not_to have_key('context') + end + + it 'omits :context when empty hash' do + sink.log(:info, 'mcp', 0, { message: 'test', context: {} }) + expect(last_entry).not_to have_key('context') + end + end + + context 'with an Exception in :exception' do + let(:error) do + StandardError.new('boom').tap do |e| + e.set_backtrace(%w[line1 line2 line3 line4 line5 line6]) + end + end + + it 'formats the exception as a structured hash' do + sink.log(:error, 'mcp', 0, { message: 'fail', exception: error }) + ex = last_entry['exception'] + + expect(ex['class']).to eq('StandardError') + expect(ex['message']).to eq('boom') + end + + it 'includes backtrace at DEBUG log level' do + # Register at LEV_3 (DEBUG) to enable backtrace + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3) + + sink.log(:error, 'mcp', 0, { message: 'fail', exception: error }) + ex = last_entry['exception'] + + expect(ex['backtrace']).to be_an(Array) + expect(ex['backtrace'].length).to eq(5) # first(5) + expect(ex['backtrace']).not_to include('line6') + + deregister_log_source(log_source) + end + + it 'omits backtrace below DEBUG log level' do + # Register at LEV_0 — below BACKTRACE_LOG_LEVEL (3) + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0) + + sink.log(:error, 'mcp', 0, { message: 'fail', exception: error }) + ex = last_entry['exception'] + + expect(ex).not_to have_key('backtrace') + + deregister_log_source(log_source) + end + + it 'handles exception with nil backtrace' do + no_bt = RuntimeError.new('no trace') + # backtrace is nil by default when not raised + + sink.log(:error, 'mcp', 0, { message: 'fail', exception: no_bt }) + ex = last_entry['exception'] + + expect(ex['class']).to eq('RuntimeError') + expect(ex['message']).to eq('no trace') + end + + it 'passes non-Exception :exception values through' do + sink.log(:error, 'mcp', 0, { message: 'fail', exception: 'string error' }) + expect(last_entry['exception']).to eq('string error') + end + end + + context 'context summarization at non-DEBUG level' do + before do + # Register at LEV_0 so debug_log_level? returns false + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + end + + it 'truncates heavy keys (:result, :body, :error)' do + long_value = 'x' * 2000 + sink.log(:info, 'mcp', 0, { message: 'test', context: { result: long_value } }) + ctx = last_entry['context'] + + expect(ctx['result']).to include('truncated') + expect(ctx['result'].length).to be < 2000 + end + + it 'passes through non-heavy scalar keys unchanged' do + sink.log(:info, 'mcp', 0, { message: 'test', context: { method: 'tools/call', elapsed_ms: 42 } }) + ctx = last_entry['context'] + + expect(ctx['method']).to eq('tools/call') + expect(ctx['elapsed_ms']).to eq(42) + end + + it 'truncates heavy keys inside :response sub-hash' do + long_result = 'y' * 2000 + context = { response: { status: 200, result: long_result } } + sink.log(:info, 'mcp', 0, { message: 'test', context: context }) + resp = last_entry['context']['response'] + + expect(resp['status']).to eq(200) + expect(resp['result']).to include('truncated') + end + + it 'does not truncate short values' do + sink.log(:info, 'mcp', 0, { message: 'test', context: { result: 'short' } }) + ctx = last_entry['context'] + + expect(ctx['result']).to eq('short') + end + end + + context 'context at DEBUG level' do + before do + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + end + + it 'passes context through without summarization' do + long_value = 'x' * 2000 + sink.log(:info, 'mcp', 0, { message: 'test', context: { result: long_value } }) + ctx = last_entry['context'] + + expect(ctx['result']).to eq(long_value) + expect(ctx['result']).not_to include('truncated') + end + end + end + + describe '#cleanup' do + it 'closes the stream' do + sink.cleanup + expect(stream).to be_closed + end + end +end diff --git a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb new file mode 100644 index 0000000000000..5b560a81b5c26 --- /dev/null +++ b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'stringio' +require 'json' + +RSpec.describe Msf::MCP::Logging::Sinks::Sanitizing do + let(:stream) { StringIO.new } + let(:inner_sink) { Msf::MCP::Logging::Sinks::JsonStream.new(stream) } + let(:sink) { described_class.new(inner_sink) } + let(:log_source) { Msf::MCP::LOG_SOURCE } + + # Helper: parse the last JSON log entry from the stream + def last_log_entry + stream.rewind + lines = stream.read.strip.split("\n") + JSON.parse(lines.last) + end + + describe '#log' do + it 'delegates to the inner sink' do + sink.log(:info, 'mcp', 0, 'hello') + expect(last_log_entry['message']).to include('hello') + end + + it 'passes severity, source, and level through' do + sink.log(:error, 'mcp', 2, 'test') + entry = last_log_entry + expect(entry['severity']).to eq('ERROR') + expect(entry['source']).to eq('mcp') + expect(entry['level']).to eq('2') + end + + it 'passes through innocuous messages unchanged' do + sink.log(:info, 'mcp', 0, 'connected to host') + expect(last_log_entry['message']).to include('connected to host') + end + end + + describe '#cleanup' do + it 'delegates cleanup to the inner sink' do + expect(inner_sink).to receive(:cleanup) + sink.cleanup + end + end + + describe 'string sanitization' do + it 'redacts password key-value pairs' do + sink.log(:info, 'mcp', 0, 'password: hunter2') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('hunter2') + end + + it 'redacts password with equals sign' do + sink.log(:info, 'mcp', 0, 'password=s3cret') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('s3cret') + end + + it 'redacts token key-value pairs' do + sink.log(:info, 'mcp', 0, 'token=abc123xyz') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('abc123xyz') + end + + it 'redacts bearer tokens' do + sink.log(:info, 'mcp', 0, 'bearer eyJhbGci.payload.sig') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('eyJhbGci.payload.sig') + end + + it 'redacts token header style' do + sink.log(:info, 'mcp', 0, 'token abc123def') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('abc123def') + end + + it 'redacts API keys' do + sink.log(:info, 'mcp', 0, 'api_key=sk_live_1234567890') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('sk_live_1234567890') + end + + it 'redacts secret keys' do + sink.log(:info, 'mcp', 0, 'secret_key: my_secret_value') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('my_secret_value') + end + + it 'redacts credential values' do + sink.log(:info, 'mcp', 0, 'credential=admin_cred') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('admin_cred') + end + + it 'redacts auth values' do + sink.log(:info, 'mcp', 0, 'auth: some_auth_value') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('some_auth_value') + end + + it 'is case-insensitive' do + sink.log(:info, 'mcp', 0, 'PASSWORD: upper_case_secret') + msg = last_log_entry['message'] + expect(msg).to include('[REDACTED]') + expect(msg).not_to include('upper_case_secret') + end + + it 'does not redact non-sensitive strings' do + sink.log(:info, 'mcp', 0, 'Module loaded: exploit/windows/smb/ms17_010') + expect(last_log_entry['message']).to eq('Module loaded: exploit/windows/smb/ms17_010') + end + end + + describe 'hash sanitization' do + it 'redacts scalar values under sensitive keys' do + msg = { message: 'test', context: { password: 'secret123', host: 'localhost' } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + expect(ctx['password']).to eq('[REDACTED]') + expect(ctx['host']).to eq('localhost') + end + + it 'redacts all SENSITIVE_KEYS patterns' do + %w[password token secret api_key api_secret credential auth_token bearer access_token private_key].each do |key| + stream.truncate(0) + stream.rewind + msg = { message: 'test', context: { key.to_sym => 'sensitive_value' } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + expect(ctx[key]).to eq('[REDACTED]'), "Expected #{key} to be redacted" + end + end + + it 'recurses into Hash values under sensitive keys' do + msg = { message: 'test', context: { token: { password: 'deep_secret', safe: 'visible' } } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + # Hash under sensitive key is recursed, not replaced with REDACTED + expect(ctx['token']).to be_a(Hash) + expect(ctx['token']['password']).to eq('[REDACTED]') + expect(ctx['token']['safe']).to eq('visible') + end + + it 'recurses into Array values under sensitive keys' do + msg = { message: 'test', context: { token: ['value1', 'password: secret'] } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + expect(ctx['token']).to be_an(Array) + # String elements in the array get pattern-based sanitization + expect(ctx['token'][1]).to include('[REDACTED]') + expect(ctx['token'][1]).not_to include('secret') + end + + it 'recurses into non-sensitive hash values' do + msg = { message: 'test', context: { data: { password: 'nested_secret' } } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + expect(ctx['data']['password']).to eq('[REDACTED]') + end + + it 'passes through non-string/hash/array types' do + msg = { message: 'test', context: { count: 42, enabled: true, value: nil } } + sink.log(:info, 'mcp', 0, msg) + ctx = last_log_entry['context'] + + expect(ctx['count']).to eq(42) + expect(ctx['enabled']).to be true + expect(ctx['value']).to be_nil + end + + it 'sanitizes strings in arrays' do + msg = { message: 'test', context: { items: ['safe', 'password=secret', 'also safe'] } } + sink.log(:info, 'mcp', 0, msg) + items = last_log_entry['context']['items'] + + expect(items[0]).to eq('safe') + expect(items[1]).to include('[REDACTED]') + expect(items[1]).not_to include('secret') + expect(items[2]).to eq('also safe') + end + end + + describe 'exception handling' do + let(:error) do + StandardError.new('password=s3cret in message').tap do |e| + e.set_backtrace([ + '/opt/metasploit-framework/lib/msf/core/mcp/server.rb:42:in `start`', + '/opt/metasploit-framework/lib/msf/core/mcp/application.rb:100:in `run`', + '/home/user/lib/custom/code.rb:10:in `call`', + 'line4', 'line5', 'line6' + ]) + end + end + + it 'formats Exception as structured hash' do + msg = { message: 'error occurred', exception: error } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex['class']).to eq('StandardError') + expect(ex['message']).to be_a(String) + end + + it 'sanitizes the exception message' do + msg = { message: 'error occurred', exception: error } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex['message']).to include('[REDACTED]') + expect(ex['message']).not_to include('s3cret') + end + + context 'at DEBUG log level' do + before do + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + end + + it 'includes backtrace' do + msg = { message: 'error', exception: error } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex['backtrace']).to be_an(Array) + end + + it 'limits backtrace to 5 frames' do + msg = { message: 'error', exception: error } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex['backtrace'].length).to eq(5) + end + + it 'strips install path prefix from backtrace frames' do + msg = { message: 'error', exception: error } + sink.log(:error, 'mcp', 0, msg) + bt = last_log_entry['exception']['backtrace'] + + expect(bt[0]).to start_with('lib/msf/') + expect(bt[0]).not_to include('/opt/metasploit-framework/') + end + + it 'sanitizes backtrace strings containing sensitive patterns' do + error_with_sensitive_bt = StandardError.new('fail').tap do |e| + e.set_backtrace(['lib/msf/core/mcp/server.rb:42:in `token=abc123`']) + end + msg = { message: 'error', exception: error_with_sensitive_bt } + sink.log(:error, 'mcp', 0, msg) + bt = last_log_entry['exception']['backtrace'] + + expect(bt[0]).to include('[REDACTED]') + expect(bt[0]).not_to include('abc123') + end + end + + context 'below DEBUG log level' do + before do + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + end + + it 'omits backtrace' do + msg = { message: 'error', exception: error } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex).not_to have_key('backtrace') + end + end + + it 'handles exception with nil backtrace' do + no_bt = RuntimeError.new('no trace') + msg = { message: 'error', exception: no_bt } + sink.log(:error, 'mcp', 0, msg) + ex = last_log_entry['exception'] + + expect(ex['class']).to eq('RuntimeError') + expect(ex['message']).to eq('no trace') + end + + it 'passes non-Exception :exception values through after sanitization' do + msg = { message: 'error', exception: 'password=oops' } + sink.log(:error, 'mcp', 0, msg) + + expect(last_log_entry['exception']).to include('[REDACTED]') + expect(last_log_entry['exception']).not_to include('oops') + end + end +end diff --git a/spec/lib/msf/core/mcp/metasploit/client_spec.rb b/spec/lib/msf/core/mcp/metasploit/client_spec.rb new file mode 100644 index 0000000000000..c1291cdd4cdd7 --- /dev/null +++ b/spec/lib/msf/core/mcp/metasploit/client_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Metasploit::Client do + let(:jsonrpc_client) { double('Msf::MCP::Metasploit::JsonRpcClient') } + let(:messagepack_client) { double('Msf::MCP::Metasploit::MessagePackClient') } + + describe '#initialize with JSON-RPC' do + it 'creates JsonRpcClient for json-rpc api_type' do + expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with( + host: 'localhost', + port: 8081, + ssl: true, + endpoint: '/api/v1/json-rpc', + token: 'test_token' + ).and_return(jsonrpc_client) + + client = described_class.new( + api_type: 'json-rpc', + host: 'localhost', + port: 8081, + ssl: true, + endpoint: '/api/v1/json-rpc', + token: 'test_token' + ) + + expect(client.instance_variable_get(:@client)).to eq(jsonrpc_client) + end + end + + describe '#initialize with MessagePack' do + it 'creates MessagePackClient for messagepack api_type' do + expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with( + host: 'localhost', + port: 55553, + ssl: true, + endpoint: '/api/' + ).and_return(messagepack_client) + + client = described_class.new( + api_type: 'messagepack', + host: 'localhost', + port: 55553, + ssl: true + ) + + expect(client.instance_variable_get(:@client)).to eq(messagepack_client) + end + end + + describe '#initialize with invalid API type' do + it 'raises Error for unknown api_type' do + expect { + described_class.new(api_type: 'invalid', host: 'localhost', port: 8081) + }.to raise_error(Msf::MCP::Error, /Invalid API type/) + end + end + + describe '#create_client' do + it 'uses default MessagePack endpoint when none provided' do + expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with( + host: 'localhost', + port: 55553, + endpoint: Msf::MCP::Metasploit::MessagePackClient::DEFAULT_ENDPOINT, + ssl: true + ) + + described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553) + end + + it 'uses default JSON-RPC endpoint when none provided' do + expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with( + host: 'localhost', + port: 8081, + endpoint: Msf::MCP::Metasploit::JsonRpcClient::DEFAULT_ENDPOINT, + ssl: true, + token: 'tok' + ) + + described_class.new(api_type: 'json-rpc', host: 'localhost', port: 8081, token: 'tok') + end + + it 'uses custom endpoint when provided for MessagePack' do + expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with( + host: 'localhost', + port: 55553, + endpoint: '/custom/api/', + ssl: false + ) + + described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553, endpoint: '/custom/api/', ssl: false) + end + + it 'uses custom endpoint when provided for JSON-RPC' do + expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with( + host: 'remote', + port: 9090, + endpoint: '/custom/jsonrpc', + ssl: false, + token: 'my_token' + ) + + described_class.new(api_type: 'json-rpc', host: 'remote', port: 9090, endpoint: '/custom/jsonrpc', ssl: false, token: 'my_token') + end + + it 'defaults ssl to true' do + expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with( + hash_including(ssl: true) + ) + + described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553) + end + + it 'includes the invalid api_type in the error message' do + expect { + described_class.new(api_type: 'grpc', host: 'localhost', port: 8081) + }.to raise_error(Msf::MCP::Error, 'Invalid API type: grpc') + end + end + + describe 'method delegation' do + let(:client) do + allow(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).and_return(jsonrpc_client) + described_class.new(api_type: 'json-rpc', host: 'localhost', port: 8081, token: 'test') + end + + it 'delegates search_modules to underlying client' do + allow(jsonrpc_client).to receive(:search_modules).with('smb').and_return(['module1']) + expect(client.search_modules('smb')).to eq(['module1']) + end + + it 'delegates authenticate to underlying client' do + allow(jsonrpc_client).to receive(:authenticate).with('user', 'pass').and_return('test') + expect(client.authenticate('user', 'pass')).to eq('test') + end + + it 'delegates module_info to underlying client' do + allow(jsonrpc_client).to receive(:module_info).with('exploit', 'test').and_return({ 'name' => 'test' }) + expect(client.module_info('exploit', 'test')).to eq({ 'name' => 'test' }) + end + + it 'delegates db_hosts to underlying client' do + allow(jsonrpc_client).to receive(:db_hosts).with({ workspace: 'default' }).and_return({ 'hosts' => [] }) + expect(client.db_hosts({ workspace: 'default' })).to eq({ 'hosts' => [] }) + end + + it 'delegates db_services to underlying client' do + allow(jsonrpc_client).to receive(:db_services).with({ workspace: 'default' }).and_return({ 'services' => [] }) + expect(client.db_services({ workspace: 'default' })).to eq({ 'services' => [] }) + end + + it 'delegates db_vulns to underlying client' do + allow(jsonrpc_client).to receive(:db_vulns).with({ workspace: 'default' }).and_return({ 'vulns' => [] }) + expect(client.db_vulns({ workspace: 'default' })).to eq({ 'vulns' => [] }) + end + + it 'delegates db_creds to underlying client' do + allow(jsonrpc_client).to receive(:db_creds).with({ workspace: 'default' }).and_return({ 'creds' => [] }) + expect(client.db_creds({ workspace: 'default' })).to eq({ 'creds' => [] }) + end + + it 'delegates db_loot to underlying client' do + allow(jsonrpc_client).to receive(:db_loot).with({ workspace: 'default' }).and_return({ 'loots' => [] }) + expect(client.db_loot({ workspace: 'default' })).to eq({ 'loots' => [] }) + end + + it 'delegates db_notes to underlying client' do + allow(jsonrpc_client).to receive(:db_notes).with({ workspace: 'default' }).and_return({ 'notes' => [] }) + expect(client.db_notes({ workspace: 'default' })).to eq({ 'notes' => [] }) + end + + it 'delegates shutdown to underlying client' do + allow(jsonrpc_client).to receive(:shutdown) + client.shutdown + expect(jsonrpc_client).to have_received(:shutdown) + end + end +end diff --git a/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb new file mode 100644 index 0000000000000..13bf35158789c --- /dev/null +++ b/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Metasploit::JsonRpcClient do + let(:host) { 'localhost' } + let(:port) { 8081 } + let(:endpoint) { '/api/v1/json-rpc' } + let(:token) { 'test_token_123' } + let(:ssl) { false } + let(:client) { described_class.new(host: host, port: port, endpoint: endpoint, token: token, ssl: ssl) } + + describe '#initialize' do + it 'sets instance variables' do + expect(client.instance_variable_get(:@host)).to eq(host) + expect(client.instance_variable_get(:@port)).to eq(port) + expect(client.instance_variable_get(:@endpoint)).to eq(endpoint) + expect(client.instance_variable_get(:@token)).to eq(token) + expect(client.instance_variable_get(:@ssl)).to eq(ssl) + expect(client.instance_variable_get(:@request_id)).to eq(0) + end + + it 'defaults ssl to true when not specified' do + client_with_defaults = described_class.new(host: host, port: port, token: token) + expect(client_with_defaults.instance_variable_get(:@ssl)).to eq(true) + end + end + + describe '#authenticate' do + it 'is a no-op that returns the existing token' do + result = client.authenticate('user', 'pass') + expect(result).to eq(token) + end + + it 'does not change the token' do + client.authenticate('user', 'pass') + expect(client.instance_variable_get(:@token)).to eq(token) + end + end + + describe 'SSL configuration' do + let(:http_mock) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(http_mock) + allow(http_mock).to receive(:use_ssl=) + allow(http_mock).to receive(:verify_mode=) + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}') + ) + end + + context 'when ssl is true' do + let(:ssl) { true } + + it 'enables SSL on Net::HTTP client' do + expect(http_mock).to receive(:use_ssl=).with(true) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + + it 'sets verify_mode to VERIFY_NONE' do + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + end + + context 'when ssl is false' do + let(:ssl) { false } + + it 'disables SSL on Net::HTTP client' do + expect(http_mock).to receive(:use_ssl=).with(false) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + + it 'does not set verify_mode' do + expect(http_mock).not_to receive(:verify_mode=) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + end + + context 'when ssl is explicitly set to true in constructor' do + let(:client) { described_class.new(host: host, port: port, token: token, ssl: true) } + + it 'configures HTTPS connection' do + expect(http_mock).to receive(:use_ssl=).with(true) + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + end + + context 'with default SSL setting' do + let(:client) { described_class.new(host: host, port: port, token: token) } + + it 'uses SSL by default' do + expect(http_mock).to receive(:use_ssl=).with(true) + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 }) + end + end + end + + describe '#call_api' do + before do + allow(client).to receive(:send_request).and_return({ 'result' => { 'modules' => [] } }) + end + + it 'increments request_id' do + expect { client.call_api('module.search', ['smb']) }.to change { client.instance_variable_get(:@request_id) }.by(1) + end + + it 'sends JSON-RPC 2.0 request with correct structure' do + expect(client).to receive(:send_request) do |body| + expect(body[:jsonrpc]).to eq('2.0') + expect(body[:method]).to eq('module.search') + expect(body[:params]).to eq(['smb']) + expect(body[:id]).to eq(1) + { 'result' => {} } + end + + client.call_api('module.search', ['smb']) + end + + it 'returns result from response' do + result = client.call_api('module.search', ['smb']) + expect(result).to eq({ 'modules' => [] }) + end + + it 'raises ArgumentError when args is not an Array' do + expect { + client.call_api('module.search', 'smb') + }.to raise_error(ArgumentError, /args must be an Array/) + end + + it 'raises ArgumentError when args is a Hash' do + expect { + client.call_api('module.search', { query: 'smb' }) + }.to raise_error(ArgumentError, /args must be an Array/) + end + + it 'raises APIError when response contains error' do + allow(client).to receive(:send_request).and_return({ 'error' => { 'message' => 'Invalid method' } }) + + expect { + client.call_api('invalid.method') + }.to raise_error(Msf::MCP::Metasploit::APIError, 'Invalid method') + end + + it 'raises APIError with default message when error has no message' do + allow(client).to receive(:send_request).and_return({ 'error' => {} }) + + expect { + client.call_api('invalid.method') + }.to raise_error(Msf::MCP::Metasploit::APIError, 'Unknown error') + end + end + + describe '#shutdown' do + it 'finishes HTTP connection if started' do + http_mock = double('Net::HTTP') + allow(http_mock).to receive(:started?).and_return(true) + allow(http_mock).to receive(:finish) + + client.instance_variable_set(:@http, http_mock) + client.shutdown + + expect(http_mock).to have_received(:finish) + end + + it 'does nothing if HTTP connection not started' do + http_mock = double('Net::HTTP') + allow(http_mock).to receive(:started?).and_return(false) + + client.instance_variable_set(:@http, http_mock) + expect { client.shutdown }.not_to raise_error + end + + it 'handles nil HTTP connection' do + client.instance_variable_set(:@http, nil) + expect { client.shutdown }.not_to raise_error + end + end + + describe 'debug logging' do + let(:http_mock) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(http_mock) + allow(http_mock).to receive(:use_ssl=) + allow(http_mock).to receive(:verify_mode=) + end + + it 'does not raise when no Rex sink is registered' do + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}') + ) + expect { client.call_api('module.search', ['smb']) }.not_to raise_error + end + + it 'calls dlog for request and response' do + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}') + ) + + expect(client).to receive(:dlog).with(hash_including(message: 'JSON-RPC request'), anything, anything).ordered + expect(client).to receive(:dlog).with(hash_including(message: 'JSON-RPC response'), anything, anything).ordered + + client.call_api('module.search', ['smb']) + end + end + + describe 'API wrapper methods' do + describe '#search_modules' do + it 'calls call_api with correct method and params' do + expect(client).to receive(:call_api).with('module.search', ['smb']) + client.search_modules('smb') + end + + it 'returns search results' do + allow(client).to receive(:call_api).and_return({ 'modules' => [{ 'name' => 'auxiliary/scanner/smb/smb_version' }] }) + result = client.search_modules('smb') + expect(result['modules']).to be_an(Array) + end + end + + describe '#module_info' do + it 'calls call_api with correct method and params' do + expect(client).to receive(:call_api).with('module.info', ['exploit', 'windows/smb/ms17_010_eternalblue']) + client.module_info('exploit', 'windows/smb/ms17_010_eternalblue') + end + + it 'returns module information' do + module_data = { 'name' => 'MS17-010 EternalBlue', 'rank' => 'good' } + allow(client).to receive(:call_api).and_return(module_data) + result = client.module_info('exploit', 'windows/smb/ms17_010_eternalblue') + expect(result).to eq(module_data) + end + end + + describe '#db_hosts' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.hosts', [{}]) + client.db_hosts + end + + it 'calls call_api with options' do + expect(client).to receive(:call_api).with('db.hosts', [{workspace: 'default', limit: 10}]) + client.db_hosts(workspace: 'default', limit: 10) + end + + it 'returns hosts array' do + hosts_data = { 'hosts' => [{ 'address' => '192.168.1.1' }] } + allow(client).to receive(:call_api).and_return(hosts_data) + result = client.db_hosts + expect(result['hosts']).to be_an(Array) + end + end + + describe '#db_services' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.services', [{}]) + client.db_services + end + + it 'passes options to call_api' do + expect(client).to receive(:call_api).with('db.services', [{workspace: 'default'}]) + client.db_services(workspace: 'default') + end + end + + describe '#db_vulns' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.vulns', [{}]) + client.db_vulns + end + + it 'passes options to call_api' do + expect(client).to receive(:call_api).with('db.vulns', [{workspace: 'default'}]) + client.db_vulns(workspace: 'default') + end + end + + describe '#db_notes' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.notes', [{}]) + client.db_notes + end + + it 'passes options to call_api' do + expect(client).to receive(:call_api).with('db.notes', [{workspace: 'default'}]) + client.db_notes(workspace: 'default') + end + end + + describe '#db_creds' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.creds', [{}]) + client.db_creds + end + + it 'passes options to call_api' do + expect(client).to receive(:call_api).with('db.creds', [{workspace: 'default'}]) + client.db_creds(workspace: 'default') + end + end + + describe '#db_loot' do + it 'calls call_api with correct method' do + expect(client).to receive(:call_api).with('db.loots', [{}]) + client.db_loot + end + + it 'passes options to call_api' do + expect(client).to receive(:call_api).with('db.loots', [{workspace: 'default'}]) + client.db_loot(workspace: 'default') + end + end + end +end diff --git a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb new file mode 100644 index 0000000000000..7f5fec20730db --- /dev/null +++ b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb @@ -0,0 +1,406 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Metasploit::MessagePackClient do + let(:host) { 'localhost' } + let(:port) { 55553 } + let(:client) { described_class.new(host: host, port: port) } + + describe '#initialize' do + it 'sets instance variables' do + expect(client.instance_variable_get(:@host)).to eq(host) + expect(client.instance_variable_get(:@port)).to eq(port) + expect(client.instance_variable_get(:@endpoint)).to eq('/api/') + expect(client.instance_variable_get(:@token)).to be_nil + end + + it 'defaults ssl to true when not specified' do + expect(client.instance_variable_get(:@ssl)).to eq(true) + end + + it 'accepts ssl parameter' do + client_no_ssl = described_class.new(host: host, port: port, ssl: false) + expect(client_no_ssl.instance_variable_get(:@ssl)).to eq(false) + end + end + + describe 'SSL configuration' do + let(:http_mock) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(http_mock) + allow(http_mock).to receive(:use_ssl=) + allow(http_mock).to receive(:verify_mode=) + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'test123' }.to_msgpack) + ) + end + + context 'when ssl is true' do + let(:client) { described_class.new(host: host, port: port, ssl: true) } + + it 'enables SSL on Net::HTTP client' do + expect(http_mock).to receive(:use_ssl=).with(true) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + + it 'sets verify_mode to VERIFY_NONE' do + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + end + + context 'when ssl is false' do + let(:client) { described_class.new(host: host, port: port, ssl: false) } + + it 'disables SSL on Net::HTTP client' do + expect(http_mock).to receive(:use_ssl=).with(false) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + + it 'does not set verify_mode' do + expect(http_mock).not_to receive(:verify_mode=) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + end + + context 'with default SSL setting' do + it 'uses SSL by default' do + expect(http_mock).to receive(:use_ssl=).with(true) + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + end + + context 'when explicitly set to true' do + let(:client) { described_class.new(host: host, port: port, ssl: true) } + + it 'configures HTTPS connection' do + expect(http_mock).to receive(:use_ssl=).with(true) + expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) + client.send(:send_request, ['auth.login', 'user', 'pass']) + end + end + end + + describe '#authenticate' do + it 'sends authentication request with username and password' do + expect(client).to receive(:send_request).with(['auth.login', 'testuser', 'testpass']).and_return({ 'result' => 'success', 'token' => 'abc123' }) + + client.authenticate('testuser', 'testpass') + end + + it 'stores token from response' do + allow(client).to receive(:send_request).and_return({ 'result' => 'success', 'token' => 'abc123' }) + token = client.authenticate('testuser', 'testpass') + expect(token).to eq('abc123') + end + + it 'raises AuthenticationError when response contains error key' do + allow(client).to receive(:send_request).and_return({ 'error' => 'Invalid credentials' }) + + expect { + client.authenticate('testuser', 'badpass') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid credentials') + end + + it 'raises AuthenticationError with default message when result is not success' do + allow(client).to receive(:send_request).and_return({ 'result' => 'failure' }) + + expect { + client.authenticate('testuser', 'testpass') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Authentication failed') + end + + it 'raises AuthenticationError when send_request raises' do + allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::AuthenticationError, 'Login Failed') + + expect { + client.authenticate('testuser', 'testpass') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Login Failed') + end + end + + describe '#call_api' do + before do + client.instance_variable_set(:@token, 'abc123') + allow(client).to receive(:send_request).and_return(['module1', 'module2']) + end + + it 'sends method call with token and arguments' do + expect(client).to receive(:send_request).with(['module.search', 'abc123', 'smb']).and_return([]) + + client.call_api('module.search', ['smb']) + end + + it 'returns result from response' do + result = client.call_api('module.search', ['smb']) + expect(result).to eq(['module1', 'module2']) + end + + it 'raises ArgumentError when args is not an Array' do + expect { + client.call_api('module.search', 'smb') + }.to raise_error(ArgumentError, /args must be an Array/) + end + + it 'raises ArgumentError when args is a Hash' do + expect { + client.call_api('module.search', { query: 'smb' }) + }.to raise_error(ArgumentError, /args must be an Array/) + end + + it 'raises AuthenticationError if no token present and no credentials stored' do + client.instance_variable_set(:@token, nil) + + expect { + client.call_api('module.search', ['smb']) + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Not authenticated') + end + + it 'raises APIError when response contains error' do + allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::APIError, 'Method not found') + + expect { + client.call_api('module.search', ['smb']) + }.to raise_error(Msf::MCP::Metasploit::APIError, 'Method not found') + end + + it 'logs via elog before re-raising Msf::MCP::Error subclasses' do + allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::APIError, 'Method not found') + + expect(client).to receive(:elog).with(hash_including(message: 'MessagePack API call error'), anything, anything) + expect { + client.call_api('module.search', ['smb']) + }.to raise_error(Msf::MCP::Metasploit::APIError) + end + end + + describe '#shutdown' do + it 'clears token from memory' do + client.instance_variable_set(:@token, 'abc123') + client.shutdown + expect(client.instance_variable_get(:@token)).to be_nil + end + + it 'clears stored credentials' do + client.instance_variable_set(:@user, 'testuser') + client.instance_variable_set(:@password, 'testpass') + client.shutdown + expect(client.instance_variable_get(:@user)).to be_nil + expect(client.instance_variable_get(:@password)).to be_nil + end + + it 'finishes HTTP connection if started' do + http_mock = double('Net::HTTP') + allow(http_mock).to receive(:started?).and_return(true) + allow(http_mock).to receive(:finish) + + client.instance_variable_set(:@http, http_mock) + client.shutdown + + expect(http_mock).to have_received(:finish) + end + end + + describe '#sanitize_request_array' do + it 'redacts password in auth.login requests' do + result = client.send(:sanitize_request_array, ['auth.login', 'admin', 's3cret']) + expect(result).to eq(['auth.login', 'admin', '[REDACTED]']) + end + + it 'redacts token in API call requests' do + result = client.send(:sanitize_request_array, ['module.search', 'tok_abc123', 'smb']) + expect(result).to eq(['module.search', '[REDACTED]', 'smb']) + end + + it 'does not mutate the original array' do + original = ['auth.login', 'admin', 's3cret'] + client.send(:sanitize_request_array, original) + expect(original).to eq(['auth.login', 'admin', 's3cret']) + end + + it 'handles single-element arrays' do + result = client.send(:sanitize_request_array, ['auth.logout']) + expect(result).to eq(['auth.logout']) + end + end + + describe 'debug logging' do + let(:http_mock) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(http_mock) + allow(http_mock).to receive(:use_ssl=) + end + + it 'does not raise when no Rex sink is registered' do + client_no_ssl = described_class.new(host: host, port: port, ssl: false) + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'abc' }.to_msgpack) + ) + expect { client_no_ssl.authenticate('user', 'pass') }.not_to raise_error + end + + it 'calls dlog for request and response' do + client_no_ssl = described_class.new(host: host, port: port, ssl: false) + allow(http_mock).to receive(:request).and_return( + instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'abc' }.to_msgpack) + ) + + expect(client_no_ssl).to receive(:dlog).with(hash_including(message: 'MessagePack request'), anything, anything).ordered + expect(client_no_ssl).to receive(:dlog).with(hash_including(message: 'MessagePack response'), anything, anything).ordered + + client_no_ssl.authenticate('user', 'pass') + end + end + + describe 'automatic re-authentication' do + before do + # Initial authentication + allow(client).to receive(:send_request).with(['auth.login', 'testuser', 'testpass']).and_return( + { 'result' => 'success', 'token' => 'initial_token' } + ) + + client.authenticate('testuser', 'testpass') + end + + it 'automatically re-authenticates on invalid token error' do + call_count = 0 + + allow(client).to receive(:send_request) do |request_array| + call_count += 1 + + case call_count + when 1 + # First API call raises AuthenticationError (simulating HTTP 401) + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + when 2 + # Re-authentication request succeeds + { 'result' => 'success', 'token' => 'refreshed_token' } + when 3 + # Retry with new token succeeds + { 'modules' => [] } + else + raise 'Unexpected request sequence' + end + end + + result = client.search_modules('smb') + expect(result).to eq({ 'modules' => [] }) + expect(client.instance_variable_get(:@token)).to eq('refreshed_token') + end + + it 'stops retrying after max_retries when API calls keep failing' do + retry_attempt = 0 + + allow(client).to receive(:send_request) do |request_array| + if request_array[0] == 'auth.login' + # Re-authentication succeeds + { 'result' => 'success', 'token' => 'new_token' } + else + # Always raise AuthenticationError for API calls + retry_attempt += 1 + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + end + end + + # After exhausting retries (max_retries=2), re-raises the last error + expect { + client.search_modules('smb') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token') + + # initial call + 2 retries = 3 API attempts + expect(retry_attempt).to eq(3) + end + + it 'does not auto-reauth if credentials not stored' do + # Create new client without authenticating + new_client = described_class.new(host: host, port: port) + new_client.instance_variable_set(:@token, 'some_token') + + allow(new_client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token') + + expect { + new_client.search_modules('smb') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token') + end + + it 'logs wlog when attempting re-authentication' do + call_count = 0 + allow(client).to receive(:send_request) do |request_array| + call_count += 1 + case call_count + when 1 + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + when 2 + { 'result' => 'success', 'token' => 'new_token' } + when 3 + { 'modules' => [] } + end + end + + expect(client).to receive(:wlog).with(hash_including(message: /Attempting to re-authenticate/), anything, anything) + client.search_modules('smb') + end + + it 'raises with descriptive message when re-authentication itself fails' do + allow(client).to receive(:send_request) do |request_array| + if request_array[0] == 'auth.login' + raise Msf::MCP::Metasploit::AuthenticationError, 'Bad credentials' + else + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + end + end + + expect { + client.search_modules('smb') + }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Unable to authenticate after 2 attempts: Bad credentials/) + end + + it 'resets retry count after successful re-authentication' do + call_sequence = [] + + allow(client).to receive(:send_request) do |request_array| + if request_array[0] == 'module.search' + if call_sequence.count { |c| c == :search_call } == 0 + call_sequence << :search_call + # First search call fails + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + else + call_sequence << :search_retry + # Retry succeeds + { 'modules' => ['mod1'] } + end + elsif request_array[0] == 'auth.login' + call_sequence << :reauth + { 'result' => 'success', 'token' => "token#{call_sequence.length}" } + elsif request_array[0] == 'db.hosts' + if call_sequence.count { |c| c == :hosts_call } == 0 + call_sequence << :hosts_call + # First hosts call fails + raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token' + else + call_sequence << :hosts_retry + # Retry succeeds + { 'hosts' => [] } + end + else + raise "Unexpected request: #{request_array[0]}" + end + end + + # First call with auto-reauth + result1 = client.search_modules('smb') + expect(result1).to eq({ 'modules' => ['mod1'] }) + + # Second call with auto-reauth (retry count should have been reset) + result2 = client.db_hosts({}) + expect(result2).to eq({ 'hosts' => [] }) + + # Verify the sequence: search fail, reauth, search retry, hosts fail, reauth, hosts retry + expect(call_sequence).to eq([:search_call, :reauth, :search_retry, :hosts_call, :reauth, :hosts_retry]) + end + end +end diff --git a/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb b/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb new file mode 100644 index 0000000000000..79b2ebf70bb49 --- /dev/null +++ b/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb @@ -0,0 +1,545 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Metasploit::ResponseTransformer do + describe '.transform_modules' do + it 'transforms valid module array' do + modules = [ + { + 'name' => 'ms17_010_eternalblue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'type' => 'exploit', + 'rank' => 'excellent', + 'disclosuredate' => '2017-03-14', + 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption' + } + ] + + result = described_class.transform_modules(modules) + + expect(result).to be_an(Array) + expect(result.length).to eq(1) + expect(result[0]).to include( + name: 'ms17_010_eternalblue', + fullname: 'exploit/windows/smb/ms17_010_eternalblue', + type: 'exploit', + rank: 'excellent', + disclosure_date: '2017-03-14' + ) + # Note: description is not included in transform_modules output + end + + it 'handles nil input' do + expect(described_class.transform_modules(nil)).to eq([]) + end + + it 'handles empty array' do + expect(described_class.transform_modules([])).to eq([]) + end + + it 'handles modules with missing fields' do + modules = [{ 'name' => 'test', 'type' => 'exploit' }] + result = described_class.transform_modules(modules) + + expect(result[0]).to include(name: 'test', type: 'exploit') + expect(result[0]).not_to have_key(:description) + end + + it 'uses fullname as name if name is missing' do + modules = [{ 'fullname' => 'exploit/test', 'type' => 'exploit' }] + result = described_class.transform_modules(modules) + + expect(result[0][:name]).to eq('exploit/test') + end + end + + describe '.transform_module_info' do + let(:module_info) do + { + 'type' => 'exploit', + 'name' => 'ms17_010_eternalblue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'rank' => 'excellent', + 'disclosuredate' => '2017-03-14', + 'description' => 'Test description', + 'license' => 'MSF_LICENSE', + 'filepath' => '/opt/metasploit-framework/modules/exploits/windows/smb/ms17_010.rb', + 'arch' => ['x64', 'x86'], + 'platform' => ['windows'], + 'authors' => ['Author 1', 'Author 2'], + 'privileged' => true, + 'check' => true, + 'default_options' => { 'Option1' => 'Value1' }, + 'references' => [{'CVE' => '2017-0144'}, {'URL' => 'https://example.com'}], + 'targets' => { 0 => 'Windows 7', 1 => 'Windows 8' }, + 'default_target' => 0, + 'stance' => 'aggressive', + 'actions' => { 0 => 'Action1', 1 => 'Action2' }, + 'default_action' => 1, + 'options' => { 'RHOST' => '127.0.0.1', 'RPORT' => 445 } + } + end + + it 'transforms complete module info' do + result = described_class.transform_module_info(module_info) + + expect(result).to include( + type: 'exploit', + name: 'ms17_010_eternalblue', + fullname: 'exploit/windows/smb/ms17_010_eternalblue', + rank: 'excellent', + disclosure_date: '2017-03-14', + description: 'Test description', + license: 'MSF_LICENSE', + filepath: 'modules/exploits/windows/smb/ms17_010.rb', + architectures: ['x64', 'x86'], + platforms: ['windows'], + authors: ['Author 1', 'Author 2'], + privileged: true, + has_check_method: true, + default_options: { 'Option1' => 'Value1' }, + references: [{'CVE' => '2017-0144'}, {'URL' => 'https://example.com'}], + targets: { 0 => 'Windows 7', 1 => 'Windows 8' }, + default_target: 0, + stance: 'aggressive', + actions: { 0 => 'Action1', 1 => 'Action2' }, + default_action: 1, + options: { 'RHOST' => '127.0.0.1', 'RPORT' => 445 } + ) + end + + it 'transforms references array' do + result = described_class.transform_module_info(module_info) + + expect(result[:references]).to be_an(Array) + # Note: references are passed through as-is, not transformed to {type:, value:} format + expect(result[:references]).to eq([ + {'CVE' => '2017-0144'}, + {'URL' => 'https://example.com'} + ]) + end + + it 'handles nil input' do + expect(described_class.transform_module_info(nil)).to eq({}) + end + + it 'handles empty hash' do + expect(described_class.transform_module_info({})).to eq({}) + end + + it 'compacts nil values' do + minimal_info = { 'name' => 'test', 'filepath' => 'modules/exploits/test.rb' } + result = described_class.transform_module_info(minimal_info) + + expect(result[:name]).to eq('test') + expect(result[:filepath]).to eq('modules/exploits/test.rb') + expect(result).not_to have_key(:description) + end + + it 'strips the install path prefix from filepath' do + info = { 'name' => 'test', 'filepath' => '/home/user/.msf4/modules/post/linux/gather/enum_configs.rb' } + result = described_class.transform_module_info(info) + + expect(result[:filepath]).to eq('modules/post/linux/gather/enum_configs.rb') + end + + it 'handles nil filepath via safe navigation' do + info = { 'name' => 'test', 'filepath' => nil } + result = described_class.transform_module_info(info) + + expect(result).not_to have_key(:filepath) + end + + it 'passes through filepath that already starts with modules/' do + info = { 'name' => 'test', 'filepath' => 'modules/exploits/test.rb' } + result = described_class.transform_module_info(info) + + expect(result[:filepath]).to eq('modules/exploits/test.rb') + end + end + + describe '.transform_hosts' do + let(:hosts_response) do + { + 'hosts' => [ + { + 'address' => '192.168.1.100', + 'mac' => '00:11:22:33:44:55', + 'name' => 'testhost', + 'os_name' => 'Linux', + 'os_flavor' => 'Ubuntu', + 'os_sp' => '20.04', + 'os_lang' => 'English', + 'purpose' => 'server', + 'info' => 'Web server', + 'state' => 'alive', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + } + ] + } + end + + it 'transforms hosts with timestamps' do + result = described_class.transform_hosts(hosts_response) + + expect(result).to be_an(Array) + expect(result.length).to eq(1) + expect(result[0]).to include( + address: '192.168.1.100', + mac_address: '00:11:22:33:44:55', + hostname: 'testhost', + os_name: 'Linux', + os_flavor: 'Ubuntu', + state: 'alive' + ) + expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z') + expect(result[0][:updated_at]).to eq('2022-01-01T00:00:00Z') + end + + it 'handles nil input' do + expect(described_class.transform_hosts(nil)).to eq([]) + end + + it 'handles missing hosts array' do + expect(described_class.transform_hosts({})).to eq([]) + end + + it 'handles empty hosts array' do + expect(described_class.transform_hosts({ 'hosts' => [] })).to eq([]) + end + + it 'handles hosts with missing fields' do + minimal_response = { 'hosts' => [{ 'address' => '192.168.1.1' }] } + result = described_class.transform_hosts(minimal_response) + + expect(result[0]).to eq({ address: '192.168.1.1' }) + end + + it 'handles nil timestamps' do + response = { 'hosts' => [{ 'address' => '192.168.1.1', 'created_at' => nil }] } + result = described_class.transform_hosts(response) + + expect(result[0]).not_to have_key(:created_at) + end + + it 'handles zero timestamps' do + response = { 'hosts' => [{ 'address' => '192.168.1.1', 'created_at' => 0 }] } + result = described_class.transform_hosts(response) + + expect(result[0]).not_to have_key(:created_at) + end + end + + describe '.transform_services' do + let(:services_response) do + { + 'services' => [ + { + 'host' => '192.168.1.100', + 'port' => 80, + 'proto' => 'tcp', + 'state' => 'open', + 'name' => 'http', + 'info' => 'Apache httpd 2.4.41', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + } + ] + } + end + + it 'transforms services' do + result = described_class.transform_services(services_response) + + expect(result).to be_an(Array) + expect(result[0]).to include( + host_address: '192.168.1.100', + port: 80, + protocol: 'tcp', + state: 'open', + name: 'http', + info: 'Apache httpd 2.4.41' + ) + end + + it 'handles nil input' do + expect(described_class.transform_services(nil)).to eq([]) + end + + it 'handles empty services array' do + expect(described_class.transform_services({ 'services' => [] })).to eq([]) + end + end + + describe '.transform_vulns' do + let(:vulns_response) do + { + 'vulns' => [ + { + 'host' => '192.168.1.100', + 'port' => 445, + 'proto' => 'tcp', + 'name' => 'MS17-010', + 'info' => 'SMB vulnerability', + 'refs' => 'CVE-2017-0144,MSB-2017-010', + 'time' => 1609459200 + } + ] + } + end + + it 'transforms vulnerabilities' do + result = described_class.transform_vulns(vulns_response) + + expect(result).to be_an(Array) + expect(result[0]).to include( + host: '192.168.1.100', + port: 445, + protocol: 'tcp', + name: 'MS17-010' + ) + expect(result[0][:references]).to eq(['CVE-2017-0144', 'MSB-2017-010']) + expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z') + # Note: 'info' field is not included in transform_vulns output + end + + it 'handles nil input' do + expect(described_class.transform_vulns(nil)).to eq([]) + end + + it 'handles empty vulns array' do + expect(described_class.transform_vulns({ 'vulns' => [] })).to eq([]) + end + + it 'handles nil refs' do + response = { 'vulns' => [{ 'host' => '192.168.1.1', 'refs' => nil }] } + result = described_class.transform_vulns(response) + + expect(result[0]).not_to have_key(:refs) + end + + it 'handles empty refs string' do + response = { 'vulns' => [{ 'host' => '192.168.1.1', 'refs' => '' }] } + result = described_class.transform_vulns(response) + + expect(result[0]).not_to have_key(:refs) + end + end + + describe '.transform_notes' do + let(:notes_response) do + { + 'notes' => [ + { + 'host' => '192.168.1.100', + 'service' => 'http', + 'type' => 'web.form', + 'data' => 'Login form found', + 'critical' => false, + 'seen' => true, + 'time' => 1609459200 + } + ] + } + end + + it 'transforms notes' do + result = described_class.transform_notes(notes_response) + + expect(result).to be_an(Array) + expect(result[0]).to include( + host: '192.168.1.100', + service_name_or_port: 'http', + note_type: 'web.form', + data: 'Login form found' + ) + expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z') + # Note: 'critical' and 'seen' fields are not included in transform_notes output + end + + it 'handles nil input' do + expect(described_class.transform_notes(nil)).to eq([]) + end + + it 'handles empty notes array' do + expect(described_class.transform_notes({ 'notes' => [] })).to eq([]) + end + + it 'handles ntype as fallback for type' do + response = { 'notes' => [{ 'host' => '192.168.1.1', 'ntype' => 'test' }] } + result = described_class.transform_notes(response) + + expect(result[0][:note_type]).to eq('test') + end + end + + describe '.transform_creds' do + let(:creds_response) do + { + 'creds' => [ + { + 'host' => '192.168.1.100', + 'port' => 22, + 'proto' => 'tcp', + 'sname' => 'ssh', + 'user' => 'admin', + 'pass' => 'password123', + 'type' => 'password', + 'updated_at' => 1609459200 + } + ] + } + end + + it 'transforms credentials' do + result = described_class.transform_creds(creds_response) + + expect(result).to be_an(Array) + expect(result[0]).to include( + host: '192.168.1.100', + port: 22, + protocol: 'tcp', + service_name: 'ssh', + user: 'admin', + secret: 'password123', + type: 'password' + ) + expect(result[0][:updated_at]).to eq('2021-01-01T00:00:00Z') + end + + it 'handles nil input' do + expect(described_class.transform_creds(nil)).to eq([]) + end + + it 'handles empty creds array' do + expect(described_class.transform_creds({ 'creds' => [] })).to eq([]) + end + end + + describe '.transform_loot' do + let(:loot_response) do + { + 'loots' => [ + { + 'host' => '192.168.1.100', + 'service' => 'http', + 'ltype' => 'credentials', + 'ctype' => 'text/plain', + 'name' => 'passwords.txt', + 'info' => 'Recovered passwords', + 'data' => 'user1:pass1', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + } + ] + } + end + + it 'transforms loot' do + result = described_class.transform_loot(loot_response) + + expect(result).to be_an(Array) + expect(result[0]).to include( + host: '192.168.1.100', + service_name_or_port: 'http', + loot_type: 'credentials', + content_type: 'text/plain', + name: 'passwords.txt', + info: 'Recovered passwords', + data: 'user1:pass1' + ) + expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z') + expect(result[0][:updated_at]).to eq('2022-01-01T00:00:00Z') + end + + it 'handles nil input' do + expect(described_class.transform_loot(nil)).to eq([]) + end + + it 'handles empty loots array' do + expect(described_class.transform_loot({ 'loots' => [] })).to eq([]) + end + end + + describe '.format_timestamp' do + it 'converts Unix epoch to ISO 8601' do + timestamp = 1609459200 # 2021-01-01 00:00:00 UTC + result = described_class.send(:format_timestamp, timestamp) + + expect(result).to eq('2021-01-01T00:00:00Z') + end + + it 'handles nil' do + expect(described_class.send(:format_timestamp, nil)).to be_nil + end + + it 'handles zero' do + expect(described_class.send(:format_timestamp, 0)).to be_nil + end + + it 'handles string timestamps' do + result = described_class.send(:format_timestamp, '1609459200') + expect(result).to eq('2021-01-01T00:00:00Z') + end + end + + describe '.transform_references' do + it 'transforms array of arrays' do + refs = [['CVE', '2017-0144'], ['URL', 'https://example.com']] + result = described_class.send(:transform_references, refs) + + expect(result).to eq([ + { type: 'CVE', value: '2017-0144' }, + { type: 'URL', value: 'https://example.com' } + ]) + end + + it 'handles nil' do + expect(described_class.send(:transform_references, nil)).to be_nil + end + + it 'handles empty array' do + expect(described_class.send(:transform_references, [])).to eq([]) + end + + it 'passes through malformed references' do + refs = ['invalid', { type: 'custom' }] + result = described_class.send(:transform_references, refs) + + expect(result).to eq(['invalid', { type: 'custom' }]) + end + end + + describe '.parse_refs' do + it 'parses comma-separated string' do + refs = 'CVE-2017-0144,MS17-010,OSVDB-12345' + result = described_class.send(:parse_refs, refs) + + expect(result).to eq(['CVE-2017-0144', 'MS17-010', 'OSVDB-12345']) + end + + it 'strips whitespace' do + refs = 'CVE-2017-0144, MS17-010 , OSVDB-12345' + result = described_class.send(:parse_refs, refs) + + expect(result).to eq(['CVE-2017-0144', 'MS17-010', 'OSVDB-12345']) + end + + it 'handles nil' do + expect(described_class.send(:parse_refs, nil)).to be_nil + end + + it 'handles empty string' do + expect(described_class.send(:parse_refs, '')).to be_nil + end + + it 'filters empty elements' do + refs = 'CVE-2017-0144,,,MS17-010' + result = described_class.send(:parse_refs, refs) + + expect(result).to eq(['CVE-2017-0144', 'MS17-010']) + end + end +end diff --git a/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb b/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb new file mode 100644 index 0000000000000..55ad8544fd1ea --- /dev/null +++ b/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'tempfile' +require 'json' +require 'rack' + +RSpec.describe Msf::MCP::Middleware::RequestLogger do + let(:log_file) { Tempfile.new(['request_logger_test', '.log']).tap(&:close).path } + let(:log_source) { Msf::MCP::LOG_SOURCE } + + # A simple Rack app that returns a configurable response + let(:response_status) { 200 } + let(:response_headers) { { 'Content-Type' => 'application/json' } } + let(:response_body) { ['{"jsonrpc":"2.0","id":1,"result":{}}'] } + let(:inner_app) { ->(_env) { [response_status, response_headers, response_body] } } + let(:middleware) { described_class.new(inner_app) } + + before do + deregister_log_source(log_source) if log_source_registered?(log_source) + register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_3) + end + + after do + deregister_log_source(log_source) if log_source_registered?(log_source) + File.delete(log_file) if File.exist?(log_file) + end + + # Helper: parse the last JSON log entry + def last_log_entry + JSON.parse(File.read(log_file).strip.split("\n").last) + end + + # Helper: build a minimal Rack env for a given HTTP method + def rack_env_for(method, body: nil, headers: {}) + env = Rack::MockRequest.env_for('http://localhost:3000/mcp', method: method) + if body + io = StringIO.new(body) + env['rack.input'] = io + end + headers.each do |key, value| + rack_key = "HTTP_#{key.upcase.tr('-', '_')}" + env[rack_key] = value + end + env + end + + describe '#call' do + it 'delegates to the inner app and returns its response' do + env = rack_env_for('POST', body: '{"jsonrpc":"2.0","method":"ping","id":1}') + status, headers, body = middleware.call(env) + + expect(status).to eq(200) + expect(headers).to eq(response_headers) + expect(body).to eq(response_body) + end + + it 'logs after the inner app responds' do + env = rack_env_for('POST', body: '{"jsonrpc":"2.0","method":"ping","id":1}') + middleware.call(env) + + expect(File.read(log_file)).not_to be_empty + end + end + + describe 'POST requests' do + context 'with a normal JSON-RPC request' do + it 'logs at DEBUG level with method and id' do + body = '{"jsonrpc":"2.0","method":"tools/call","id":42,"params":{"name":"test"}}' + env = rack_env_for('POST', body: body) + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('DEBUG') + expect(entry['message']).to include('tools/call') + expect(entry['message']).to include('id=42') + end + + it 'includes elapsed time in the message' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + + expect(last_log_entry['message']).to match(/\d+(\.\d+)?ms/) + end + + it 'includes request fields in context' do + body = '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"test"}}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['request']['method']).to eq('tools/call') + expect(ctx['request']['id']).to eq(1) + end + + it 'includes response status in context' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']['status']).to eq(200) + end + + it 'includes response result in context' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']['result']).to eq({}) + end + end + + context 'with a notification (no id)' do + it 'logs at DEBUG level as a notification' do + body = '{"jsonrpc":"2.0","method":"notifications/initialized"}' + env = rack_env_for('POST', body: body) + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('DEBUG') + expect(entry['message']).to include('Notification') + expect(entry['message']).to include('notifications/initialized') + end + end + + context 'with an HTTP error response' do + let(:response_status) { 400 } + let(:response_body) { ['{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"}}'] } + + it 'logs at ERROR level' do + body = '{"jsonrpc":"2.0","method":"invalid","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('ERROR') + expect(entry['message']).to include('400') + expect(entry['message']).to include('invalid') + end + + it 'includes error details in response context' do + body = '{"jsonrpc":"2.0","method":"invalid","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']['error']).to be_a(Hash) + expect(ctx['response']['error']['message']).to eq('Method not found') + end + end + + context 'with invalid JSON in request body' do + it 'logs with unknown method name' do + env = rack_env_for('POST', body: 'not valid json{{{') + middleware.call(env) + + expect(last_log_entry['message']).to include('unknown') + end + end + + context 'with empty response body' do + let(:response_body) { [] } + + it 'does not include result or error in response context' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']).not_to have_key('result') + expect(ctx['response']).not_to have_key('error') + end + end + + context 'with non-Array response body (SSE stream)' do + let(:response_body) { proc { |_| } } + + it 'does not include result or error in response context' do + body = '{"jsonrpc":"2.0","method":"tools/call","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']).not_to have_key('result') + expect(ctx['response']).not_to have_key('error') + end + end + + context 'with invalid JSON in response body' do + let(:response_body) { ['not json{{{'] } + + it 'does not include result or error in response context' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + ctx = last_log_entry['context'] + + expect(ctx['response']).not_to have_key('result') + expect(ctx['response']).not_to have_key('error') + end + end + end + + describe 'GET requests' do + it 'logs SSE stream opened at INFO level' do + env = rack_env_for('GET') + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('SSE stream opened') + end + + it 'includes elapsed time' do + env = rack_env_for('GET') + middleware.call(env) + + expect(last_log_entry['message']).to match(/\d+(\.\d+)?ms/) + end + + it 'includes session_id from header when present' do + env = rack_env_for('GET', headers: { 'Mcp-Session-Id' => 'sess-abc' }) + middleware.call(env) + + expect(last_log_entry['context']['session_id']).to eq('sess-abc') + end + + it 'includes response status in context' do + env = rack_env_for('GET') + middleware.call(env) + + expect(last_log_entry['context']['response']['status']).to eq(200) + end + end + + describe 'DELETE requests' do + it 'logs session deleted at INFO level' do + env = rack_env_for('DELETE') + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('Session deleted') + end + end + + describe 'other HTTP methods' do + it 'logs at DEBUG level with method name and status' do + env = rack_env_for('OPTIONS') + middleware.call(env) + entry = last_log_entry + + expect(entry['severity']).to eq('DEBUG') + expect(entry['message']).to include('OPTIONS') + expect(entry['message']).to include('200') + end + end + + describe 'session ID extraction' do + it 'extracts session_id from request header' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body, headers: { 'Mcp-Session-Id' => 'req-sess' }) + middleware.call(env) + + expect(last_log_entry['context']['session_id']).to eq('req-sess') + end + + it 'falls back to session_id from response header' do + response_headers['Mcp-Session-Id'] = 'resp-sess' + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + + expect(last_log_entry['context']['session_id']).to eq('resp-sess') + end + + it 'omits session_id when not present' do + body = '{"jsonrpc":"2.0","method":"ping","id":1}' + env = rack_env_for('POST', body: body) + middleware.call(env) + + expect(last_log_entry['context']).not_to have_key('session_id') + end + end + + describe 'content type in response context' do + it 'includes Content-Type when present' do + env = rack_env_for('GET') + middleware.call(env) + + expect(last_log_entry['context']['response']['content_type']).to eq('application/json') + end + + it 'omits Content-Type when not present' do + response_headers.delete('Content-Type') + env = rack_env_for('GET') + middleware.call(env) + + expect(last_log_entry['context']['response']).not_to have_key('content_type') + end + end +end diff --git a/spec/lib/msf/core/mcp/rpc_manager_spec.rb b/spec/lib/msf/core/mcp/rpc_manager_spec.rb new file mode 100644 index 0000000000000..4f3393402dbfa --- /dev/null +++ b/spec/lib/msf/core/mcp/rpc_manager_spec.rb @@ -0,0 +1,1038 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' +require 'socket' +require 'stringio' + +RSpec.describe Msf::MCP::RpcManager do + let(:output) { StringIO.new } + let(:default_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + ssl: true, + endpoint: '/api/', + user: 'testuser', + password: 'testpass', + auto_start_rpc: true + } + } + end + + describe '#initialize' do + it 'initializes with config and output' do + manager = described_class.new(config: default_config, output: output) + expect(manager).to be_a(described_class) + end + + it 'is not managing an RPC server initially' do + manager = described_class.new(config: default_config, output: output) + expect(manager.rpc_managed?).to be false + end + + it 'has no rpc_pid initially' do + manager = described_class.new(config: default_config, output: output) + expect(manager.rpc_pid).to be_nil + end + end + + describe '#rpc_available?' do + let(:manager) { described_class.new(config: default_config, output: output) } + + context 'when RPC server is listening' do + it 'returns true' do + tcp_socket = instance_double('Rex::Socket::Tcp') + allow(Rex::Socket::Tcp).to receive(:create).with( + 'PeerHost' => 'localhost', + 'PeerPort' => 55553 + ).and_return(tcp_socket) + allow(tcp_socket).to receive(:close) + + expect(manager.rpc_available?).to be true + end + + it 'closes the probe connection' do + tcp_socket = instance_double('Rex::Socket::Tcp') + allow(Rex::Socket::Tcp).to receive(:create).with( + 'PeerHost' => 'localhost', + 'PeerPort' => 55553 + ).and_return(tcp_socket) + expect(tcp_socket).to receive(:close) + + manager.rpc_available? + end + end + + context 'when RPC server is not listening' do + it 'returns false on connection refused' do + allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError) + + expect(manager.rpc_available?).to be false + end + + it 'returns false on host unreachable' do + allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError) + + expect(manager.rpc_available?).to be false + end + + it 'returns false on network unreachable' do + allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError) + + expect(manager.rpc_available?).to be false + end + + it 'returns false on socket error' do + allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError) + + expect(manager.rpc_available?).to be false + end + + it 'returns false on timeout' do + allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError) + + expect(manager.rpc_available?).to be false + end + end + + context 'with custom host and port' do + let(:custom_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1', port: 9999) + config + end + let(:manager) { described_class.new(config: custom_config, output: output) } + + it 'probes the configured host and port' do + expect(Rex::Socket::Tcp).to receive(:create).with( + 'PeerHost' => '192.0.2.1', + 'PeerPort' => 9999 + ).and_raise(Rex::ConnectionError) + + manager.rpc_available? + end + end + end + + describe '#auto_start_enabled?' do + context 'when auto_start_rpc is true in config' do + it 'returns true' do + manager = described_class.new(config: default_config, output: output) + expect(manager.auto_start_enabled?).to be true + end + end + + context 'when auto_start_rpc is false in config' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(auto_start_rpc: false) + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be false + end + end + + context 'when host is not localhost' do + it 'returns false for remote host' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be false + end + + it 'returns false for remote hostname' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: 'remote.example.com') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be false + end + end + + context 'when host is localhost variants' do + it 'returns true for localhost' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: 'localhost') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be true + end + + it 'returns true for 127.0.0.1' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: '127.0.0.1') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be true + end + + it 'returns true for ::1 (IPv6 loopback)' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: '::1') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be true + end + end + + context 'when api type is json-rpc' do + it 'returns false (JSON-RPC auto-start not supported)' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'tok123') + manager = described_class.new(config: config, output: output) + + expect(manager.auto_start_enabled?).to be false + end + end + end + + describe '#start_rpc_server' do + let(:manager) { described_class.new(config: default_config, output: output) } + + context 'when msfrpcd is available' do + before do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + allow(Process).to receive(:spawn).and_return(67890) + end + + it 'spawns msfrpcd' do + expect(Process).to receive(:spawn) + manager.start_rpc_server + end + + it 'sets the rpc_pid' do + manager.start_rpc_server + expect(manager.rpc_pid).to eq(67890) + end + + it 'marks the RPC server as managed' do + manager.start_rpc_server + expect(manager.rpc_managed?).to be true + end + + it 'passes credentials via environment variables' do + expect(Process).to receive(:spawn).with( + hash_including('MSF_RPC_USER' => 'testuser', 'MSF_RPC_PASS' => 'testpass'), + anything, + any_args + ).and_return(67890) + + manager.start_rpc_server + end + + it 'does not pass credentials as command-line arguments' do + expect(Process).to receive(:spawn) do |_env, _path, *cmd_args| + flat = cmd_args.flatten + expect(flat).not_to include('testuser') + expect(flat).not_to include('testpass') + expect(flat).not_to include('-U') + expect(flat).not_to include('-P') + end.and_return(67890) + + manager.start_rpc_server + end + + it 'passes the configured host to msfrpcd' do + expect(Process).to receive(:spawn) do |_env, _path, *cmd_args| + expect(cmd_args.flatten).to include('-a', 'localhost') + end.and_return(67890) + + manager.start_rpc_server + end + + it 'passes the configured port to msfrpcd' do + expect(Process).to receive(:spawn) do |_env, _path, *cmd_args| + expect(cmd_args.flatten).to include('-p', '55553') + end.and_return(67890) + + manager.start_rpc_server + end + + it 'passes the foreground flag to msfrpcd' do + expect(Process).to receive(:spawn) do |_env, _path, *cmd_args| + expect(cmd_args.flatten).to include('-f') + end.and_return(67890) + + manager.start_rpc_server + end + + it 'outputs a status message with PID' do + manager.start_rpc_server + expect(output.string).to include('msfrpcd') + expect(output.string).to include('67890') + end + end + + context 'when msfrpcd is not found' do + before do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(false) + end + + it 'raises an RpcStartupError' do + expect { manager.start_rpc_server }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /msfrpcd.*not found/i + ) + end + + it 'does not set rpc_pid' do + begin + manager.start_rpc_server + rescue Msf::MCP::Metasploit::RpcStartupError + # expected + end + expect(manager.rpc_pid).to be_nil + end + end + + context 'when already managing an RPC server' do + before do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + allow(Process).to receive(:spawn).and_return(12345) + manager.start_rpc_server + end + + it 'does not start a second RPC server' do + expect(Process).not_to receive(:spawn) + manager.start_rpc_server + end + + it 'outputs a message that RPC is already managed' do + manager.start_rpc_server + expect(output.string).to include('already') + end + end + end + + describe '#wait_for_rpc' do + let(:manager) { described_class.new(config: default_config, output: output) } + + context 'when RPC becomes available immediately' do + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'returns true' do + expect(manager.wait_for_rpc(timeout: 10)).to be true + end + + it 'outputs a success message' do + manager.wait_for_rpc(timeout: 10) + expect(output.string).to include('RPC server is ready') + end + end + + context 'when RPC becomes available after retries' do + before do + call_count = 0 + allow(manager).to receive(:rpc_available?) do + call_count += 1 + call_count >= 3 + end + # Stub sleep to avoid actual delays in tests + allow(manager).to receive(:sleep) + end + + it 'returns true after retrying' do + expect(manager.wait_for_rpc(timeout: 30)).to be true + end + + it 'outputs waiting messages' do + manager.wait_for_rpc(timeout: 30) + expect(output.string).to include('Waiting for RPC server') + end + end + + context 'when RPC never becomes available' do + before do + allow(manager).to receive(:rpc_available?).and_return(false) + allow(manager).to receive(:sleep) + # Make Time.now advance to simulate timeout + start_time = Time.now + call_count = 0 + allow(Time).to receive(:now) do + call_count += 1 + start_time + (call_count * 2) + end + end + + it 'raises ConnectionError after timeout' do + expect { manager.wait_for_rpc(timeout: 5) }.to raise_error( + Msf::MCP::Metasploit::ConnectionError, /timed out/i + ) + end + end + + context 'when the managed RPC process dies during wait' do + before do + allow(manager).to receive(:rpc_available?).and_return(false) + allow(manager).to receive(:sleep) + manager.instance_variable_set(:@rpc_pid, 99999) + manager.instance_variable_set(:@rpc_managed, true) + + # Simulate process dying: waitpid returns the pid (non-blocking check) + allow(Process).to receive(:waitpid).with(99999, Process::WNOHANG).and_return(99999) + end + + it 'raises RpcStartupError indicating the process exited' do + expect { manager.wait_for_rpc(timeout: 30) }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /exited|died|crashed/i + ) + end + end + + context 'with default timeout' do + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'uses a reasonable default timeout' do + # Should not raise and use the default timeout + expect { manager.wait_for_rpc }.not_to raise_error + end + end + end + + describe '#stop_rpc_server' do + let(:manager) { described_class.new(config: default_config, output: output) } + + context 'when managing an RPC server' do + before do + manager.instance_variable_set(:@rpc_pid, 12345) + manager.instance_variable_set(:@rpc_managed, true) + end + + it 'sends SIGTERM to the RPC process' do + allow(Process).to receive(:kill).with('TERM', 12345) + allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345) + + expect(Process).to receive(:kill).with('TERM', 12345) + manager.stop_rpc_server + end + + it 'waits for the process to exit' do + allow(Process).to receive(:kill).with('TERM', 12345) + expect(Process).to receive(:waitpid).with(12345, anything).and_return(12345) + + manager.stop_rpc_server + end + + it 'clears the rpc_pid after stopping' do + allow(Process).to receive(:kill).with('TERM', 12345) + allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345) + + manager.stop_rpc_server + expect(manager.rpc_pid).to be_nil + end + + it 'marks the RPC server as no longer managed' do + allow(Process).to receive(:kill).with('TERM', 12345) + allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345) + + manager.stop_rpc_server + expect(manager.rpc_managed?).to be false + end + + it 'outputs a status message' do + allow(Process).to receive(:kill).with('TERM', 12345) + allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345) + + manager.stop_rpc_server + expect(output.string).to include('Stopping') + end + + it 'handles process already dead (Errno::ESRCH)' do + allow(Process).to receive(:kill).with('TERM', 12345).and_raise(Errno::ESRCH) + + expect { manager.stop_rpc_server }.not_to raise_error + expect(manager.rpc_managed?).to be false + expect(manager.rpc_pid).to be_nil + end + + it 'handles permission error (Errno::EPERM)' do + allow(Process).to receive(:kill).with('TERM', 12345).and_raise(Errno::EPERM) + + expect { manager.stop_rpc_server }.not_to raise_error + expect(output.string).to include('permission') + end + + it 'sends SIGKILL if process does not exit after grace period' do + allow(manager).to receive(:sleep) + allow(Process).to receive(:waitpid).with(12345, Process::WNOHANG).and_return(nil, nil) + allow(Process).to receive(:waitpid).with(12345, 0).and_return(12345) + + expect(Process).to receive(:kill).with('TERM', 12345).ordered + expect(Process).to receive(:kill).with('KILL', 12345).ordered + + manager.stop_rpc_server + end + end + + context 'when not managing an RPC server' do + it 'is a no-op' do + expect(Process).not_to receive(:kill) + manager.stop_rpc_server + end + + it 'does not output any message' do + manager.stop_rpc_server + expect(output.string).to be_empty + end + end + + context 'when rpc_pid is set but rpc_managed is false' do + before do + manager.instance_variable_set(:@rpc_pid, 12345) + manager.instance_variable_set(:@rpc_managed, false) + end + + it 'does not attempt to kill the process' do + expect(Process).not_to receive(:kill) + manager.stop_rpc_server + end + end + end + + describe '#rpc_managed?' do + let(:manager) { described_class.new(config: default_config, output: output) } + + it 'returns false initially' do + expect(manager.rpc_managed?).to be false + end + + it 'returns true after starting an RPC server' do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + allow(Process).to receive(:spawn).and_return(12345) + manager.start_rpc_server + + expect(manager.rpc_managed?).to be true + end + + it 'returns false after stopping the RPC server' do + manager.instance_variable_set(:@rpc_pid, 12345) + manager.instance_variable_set(:@rpc_managed, true) + + allow(Process).to receive(:kill) + allow(Process).to receive(:waitpid).and_return(12345) + + manager.stop_rpc_server + expect(manager.rpc_managed?).to be false + end + end + + describe '#rpc_pid' do + let(:manager) { described_class.new(config: default_config, output: output) } + + it 'returns nil initially' do + expect(manager.rpc_pid).to be_nil + end + + it 'returns the PID after starting' do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + allow(Process).to receive(:spawn).and_return(54321) + manager.start_rpc_server + + expect(manager.rpc_pid).to eq(54321) + end + end + + describe 'logging' do + let(:log_file) { Tempfile.new('rpc_manager_log').tap(&:close).path } + let(:manager) { described_class.new(config: default_config, output: output) } + + before do + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + register_log_source( + Msf::MCP::LOG_SOURCE, + Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), + Rex::Logging::LEV_3 + ) + end + + after do + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + File.delete(log_file) if File.exist?(log_file) + end + + context 'when starting RPC server' do + before do + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + allow(Process).to receive(:spawn).and_return(12345) + end + + it 'logs the startup event' do + manager.start_rpc_server + expect(File.read(log_file)).to match(/Starting.*RPC/i) + end + end + + context 'when stopping RPC server' do + before do + manager.instance_variable_set(:@rpc_pid, 12345) + manager.instance_variable_set(:@rpc_managed, true) + allow(Process).to receive(:kill) + allow(Process).to receive(:waitpid).and_return(12345) + end + + it 'logs the shutdown event' do + manager.stop_rpc_server + expect(File.read(log_file)).to match(/Stopping.*RPC/i) + end + end + + context 'when RPC availability check succeeds' do + it 'logs at DEBUG level' do + tcp_socket = instance_double('Rex::Socket::Tcp') + allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_socket) + allow(tcp_socket).to receive(:close) + + manager.rpc_available? + expect(File.read(log_file)).to include('RPC server is available') + end + end + end + + describe 'configuration edge cases' do + context 'when msf_api section is missing auto_start_rpc key' do + let(:config_without_auto_start) do + config = default_config.dup + config[:msf_api] = config[:msf_api].reject { |k, _| k == :auto_start_rpc } + config + end + + it 'defaults to auto_start_enabled? returning true for localhost messagepack' do + manager = described_class.new(config: config_without_auto_start, output: output) + expect(manager.auto_start_enabled?).to be true + end + end + + context 'with SSL disabled' do + let(:no_ssl_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(ssl: false) + config + end + + it 'passes SSL setting when spawning msfrpcd' do + manager = described_class.new(config: no_ssl_config, output: output) + allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true) + + expect(Process).to receive(:spawn) do |_env, _path, *cmd_args| + expect(cmd_args.flatten).to include('-S') + end.and_return(11111) + + manager.start_rpc_server + end + end + + context 'with custom port' do + let(:custom_port_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(port: 44444) + config + end + + it 'uses the configured port for availability checks' do + manager = described_class.new(config: custom_port_config, output: output) + + expect(Rex::Socket::Tcp).to receive(:create).with( + 'PeerHost' => 'localhost', + 'PeerPort' => 44444 + ).and_raise(Rex::ConnectionError) + manager.rpc_available? + end + end + end + + describe '#credentials_provided?' do + context 'when both user and password are present' do + it 'returns true' do + manager = described_class.new(config: default_config, output: output) + expect(manager.send(:credentials_provided?)).to be true + end + end + + context 'when user is nil' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: nil) + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + + context 'when password is nil' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(password: nil) + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + + context 'when user is empty string' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: '') + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + + context 'when password is empty string' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(password: '') + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + + context 'when user is whitespace only' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: ' ') + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + + context 'when both are nil' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: nil, password: nil) + manager = described_class.new(config: config, output: output) + expect(manager.send(:credentials_provided?)).to be false + end + end + end + + describe '#token_provided?' do + context 'when token is present' do + it 'returns true' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(token: 'valid_token') + manager = described_class.new(config: config, output: output) + expect(manager.send(:token_provided?)).to be true + end + end + + context 'when token is nil' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(token: nil) + manager = described_class.new(config: config, output: output) + expect(manager.send(:token_provided?)).to be false + end + end + + context 'when token is empty string' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(token: '') + manager = described_class.new(config: config, output: output) + expect(manager.send(:token_provided?)).to be false + end + end + + context 'when token is whitespace only' do + it 'returns false' do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(token: ' ') + manager = described_class.new(config: config, output: output) + expect(manager.send(:token_provided?)).to be false + end + end + + context 'when token key is absent' do + it 'returns false' do + manager = described_class.new(config: default_config, output: output) + expect(manager.send(:token_provided?)).to be false + end + end + end + + describe '#generate_random_credentials' do + let(:config) do + c = default_config.dup + c[:msf_api] = c[:msf_api].merge(user: nil, password: nil) + c + end + let(:manager) { described_class.new(config: config, output: output) } + + it 'sets a random hex user in the config' do + manager.send(:generate_random_credentials) + expect(config[:msf_api][:user]).to match(/\A[0-9a-f]{16}\z/) + end + + it 'sets a random hex password in the config' do + manager.send(:generate_random_credentials) + expect(config[:msf_api][:password]).to match(/\A[0-9a-f]{32}\z/) + end + + it 'generates different credentials on each call' do + manager.send(:generate_random_credentials) + first_user = config[:msf_api][:user] + first_password = config[:msf_api][:password] + + manager.send(:generate_random_credentials) + expect(config[:msf_api][:user]).not_to eq(first_user) + expect(config[:msf_api][:password]).not_to eq(first_password) + end + + it 'outputs a message about generated credentials' do + manager.send(:generate_random_credentials) + expect(output.string).to include('Generated random credentials') + end + + it 'logs the event via Rex' do + log_file = Tempfile.new('creds_log').tap(&:close).path + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + register_log_source( + Msf::MCP::LOG_SOURCE, + Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), + Rex::Logging::LEV_3 + ) + + manager.send(:generate_random_credentials) + + content = File.read(log_file) + expect(content).to match(/Generated random credentials/i) + + deregister_log_source(Msf::MCP::LOG_SOURCE) + File.delete(log_file) + end + end + + describe 'ensure_rpc_available' do + let(:manager) { described_class.new(config: default_config, output: output) } + + context 'when RPC is already available' do + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'does not start a new RPC server' do + expect(manager).not_to receive(:start_rpc_server) + manager.ensure_rpc_available + end + + it 'outputs that RPC is already running' do + manager.ensure_rpc_available + expect(output.string).to include('already running') + end + end + + context 'when RPC is already available but no credentials provided' do + let(:no_creds_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: nil, password: nil) + config + end + let(:manager) { described_class.new(config: no_creds_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'raises RpcStartupError' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /already running.*no credentials/i + ) + end + end + + context 'when RPC is already available but credentials are empty strings' do + let(:empty_creds_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: '', password: '') + config + end + let(:manager) { described_class.new(config: empty_creds_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'raises RpcStartupError' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /already running.*no credentials/i + ) + end + end + + context 'when RPC is already available with JSON-RPC type and token provided' do + let(:jsonrpc_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'valid_token') + config + end + let(:manager) { described_class.new(config: jsonrpc_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'does not raise' do + expect { manager.ensure_rpc_available }.not_to raise_error + end + end + + context 'when RPC is already available with JSON-RPC type but no token' do + let(:jsonrpc_no_token_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: nil) + config + end + let(:manager) { described_class.new(config: jsonrpc_no_token_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(true) + end + + it 'raises RpcStartupError about missing token' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /already running.*no token/i + ) + end + end + + context 'when RPC is not available with JSON-RPC type' do + let(:jsonrpc_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'valid_token') + config + end + let(:manager) { described_class.new(config: jsonrpc_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(false) + end + + it 'raises RpcStartupError about auto-start not supported' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /auto-start is not supported for JSON-RPC/i + ) + end + end + + context 'when RPC is not available and auto-start is enabled' do + before do + allow(manager).to receive(:rpc_available?).and_return(false, true) + allow(manager).to receive(:auto_start_enabled?).and_return(true) + allow(manager).to receive(:start_rpc_server) + allow(manager).to receive(:wait_for_rpc).and_return(true) + end + + it 'starts the RPC server' do + expect(manager).to receive(:start_rpc_server) + manager.ensure_rpc_available + end + + it 'waits for the RPC server to become available' do + expect(manager).to receive(:wait_for_rpc) + manager.ensure_rpc_available + end + end + + context 'when RPC is not available, auto-start enabled, and no credentials' do + let(:no_creds_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(user: nil, password: nil) + config + end + let(:manager) { described_class.new(config: no_creds_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(false) + allow(manager).to receive(:auto_start_enabled?).and_return(true) + allow(manager).to receive(:start_rpc_server) + allow(manager).to receive(:wait_for_rpc).and_return(true) + end + + it 'generates random credentials' do + manager.ensure_rpc_available + expect(no_creds_config[:msf_api][:user]).not_to be_nil + expect(no_creds_config[:msf_api][:user]).not_to be_empty + expect(no_creds_config[:msf_api][:password]).not_to be_nil + expect(no_creds_config[:msf_api][:password]).not_to be_empty + end + + it 'outputs a message about generated credentials' do + manager.ensure_rpc_available + expect(output.string).to include('Generated random credentials') + end + + it 'generates a 16-character hex username' do + manager.ensure_rpc_available + expect(no_creds_config[:msf_api][:user]).to match(/\A[0-9a-f]{16}\z/) + end + + it 'generates a 32-character hex password' do + manager.ensure_rpc_available + expect(no_creds_config[:msf_api][:password]).to match(/\A[0-9a-f]{32}\z/) + end + + it 'still starts the RPC server after generating credentials' do + expect(manager).to receive(:start_rpc_server) + manager.ensure_rpc_available + end + end + + context 'when RPC is not available and auto-start is disabled' do + let(:disabled_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(auto_start_rpc: false) + config + end + let(:manager) { described_class.new(config: disabled_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(false) + end + + it 'does not start an RPC server' do + expect(manager).not_to receive(:start_rpc_server) + expect { manager.ensure_rpc_available }.to raise_error(Msf::MCP::Metasploit::RpcStartupError) + end + + it 'raises RpcStartupError about the unavailable RPC server' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /not running.*auto-start is disabled/i + ) + end + end + + context 'when RPC is not available and host is remote' do + let(:remote_config) do + config = default_config.dup + config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1') + config + end + let(:manager) { described_class.new(config: remote_config, output: output) } + + before do + allow(manager).to receive(:rpc_available?).and_return(false) + end + + it 'does not attempt to start the RPC server' do + expect(manager).not_to receive(:start_rpc_server) + expect { manager.ensure_rpc_available }.to raise_error(Msf::MCP::Metasploit::RpcStartupError) + end + + it 'raises RpcStartupError about the remote host' do + expect { manager.ensure_rpc_available }.to raise_error( + Msf::MCP::Metasploit::RpcStartupError, /not available.*192\.0\.2\.1/ + ) + 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 new file mode 100644 index 0000000000000..b75ed1ba2707b --- /dev/null +++ b/spec/lib/msf/core/mcp/security/input_validator_spec.rb @@ -0,0 +1,698 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Security::InputValidator do + describe '.validate_parameter!' do + context 'with Array constraint (enum)' do + it 'accepts value in the list' do + expect(described_class.validate_parameter!('color', 'red', %w[red green blue])).to be true + end + + it 'rejects value not in the list' do + expect { + described_class.validate_parameter!('color', 'yellow', %w[red green blue]) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid color: "yellow". Must be one of: red, green, blue') + end + + it 'includes the parameter name in error' do + expect { + described_class.validate_parameter!('fruit', 'pear', %w[apple banana]) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid fruit: "pear". Must be one of: apple, banana') + end + end + + context 'with Range constraint' do + it 'accepts value within range' do + expect(described_class.validate_parameter!('port', 80, 1..65535)).to be true + end + + it 'accepts boundary values' do + expect(described_class.validate_parameter!('port', 1, 1..65535)).to be true + expect(described_class.validate_parameter!('port', 65535, 1..65535)).to be true + end + + it 'rejects value outside range' do + expect { + described_class.validate_parameter!('port', 0, 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'port must be between 1 and 65535: 0') + end + + it 'rejects non-integer value' do + expect { + described_class.validate_parameter!('port', 'abc', 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'port must be an integer: "abc"') + end + + it 'rejects nil value' do + expect { + described_class.validate_parameter!('port', nil, 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'port cannot be nil') + end + + it 'raises ArgumentError for non-integer range' do + expect { + described_class.validate_parameter!('x', 1, 'a'..'z') + }.to raise_error(ArgumentError, 'Range constraint must be a range of integers, got String..String') + end + + context 'with Range value (range-in-range)' do + it 'accepts range within constraint' do + expect(described_class.validate_parameter!('ports', 80..443, 1..65535)).to be true + end + + it 'accepts range matching constraint bounds' do + expect(described_class.validate_parameter!('ports', 1..65535, 1..65535)).to be true + end + + it 'rejects range starting below constraint' do + expect { + described_class.validate_parameter!('ports', 0..443, 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 0..443') + end + + it 'rejects range ending above constraint' do + expect { + described_class.validate_parameter!('ports', 80..70000, 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 80..70000') + end + + it 'rejects backwards range' do + expect { + described_class.validate_parameter!('ports', 443..80, 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 443..80') + end + + it 'rejects range with non-integer bounds' do + expect { + described_class.validate_parameter!('ports', 'a'..'z', 1..65535) + }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must have integer bounds: "a".."z"') + end + end + end + + context 'with Regexp constraint' do + it 'accepts matching value' do + expect(described_class.validate_parameter!('name', 'abc_123', /\A\w+\z/)).to be true + end + + it 'rejects non-matching value' do + expect { + described_class.validate_parameter!('name', 'has spaces', /\A\w+\z/) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid name format: has spaces') + end + + it 'does not raise error for an integer value when max_size is set' do + expect(described_class.validate_parameter!('name', 33, /\A\w+\z/, max_size: 10)).to be true + end + end + + context 'with allow_nil option' do + it 'allows nil when allow_nil is true' do + expect(described_class.validate_parameter!('proto', nil, %w[tcp udp], allow_nil: true)).to be true + end + + it 'allows empty string when allow_nil is true' do + expect(described_class.validate_parameter!('proto', '', %w[tcp udp], allow_nil: true)).to be true + end + + it 'rejects nil when allow_nil is false' do + expect { + described_class.validate_parameter!('proto', nil, %w[tcp udp]) + }.to raise_error(Msf::MCP::Security::ValidationError, 'proto cannot be nil') + end + + it 'still validates non-nil values when allow_nil is true' do + expect { + described_class.validate_parameter!('proto', 'icmp', %w[tcp udp], allow_nil: true) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid proto: "icmp". Must be one of: tcp, udp') + end + end + + context 'with unsupported constraint type' do + it 'raises ArgumentError' do + expect { + described_class.validate_parameter!('x', 'y', 42) + }.to raise_error(ArgumentError, 'Unsupported constraint type: Integer') + end + end + end + + describe '.validate_ip_address!' do + context 'with valid IP addresses' do + it 'accepts valid IPv4 address' do + expect(described_class.validate_ip_address!('192.168.1.1')).to be true + end + + it 'accepts valid IPv6 address' do + expect(described_class.validate_ip_address!('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).to be true + end + + it 'accepts valid CIDR notation' do + expect(described_class.validate_ip_address!('192.168.1.0/24')).to be true + end + + it 'accepts localhost' do + expect(described_class.validate_ip_address!('127.0.0.1')).to be true + end + end + + context 'with invalid IP addresses' do + it 'rejects malformed IPv4' do + ['256.1.1.1', '192.168.1', '192.168.1.1.1', 'a.b.c.d'].each do |addr| + expect { + described_class.validate_ip_address!(addr) + }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}") + end + end + + it 'rejects out of range octets' do + expect { + described_class.validate_ip_address!('192.168.300.1') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid IP address or CIDR: 192.168.300.1') + end + + it 'rejects invalid CIDR' do + ['192.168.1.0/33', '192.168.1.0/-1', '192.168.1.0/abc'].each do |addr| + expect { + described_class.validate_ip_address!(addr) + }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}") + end + end + + it 'rejects random strings' do + ['notanip', 'test.example.com', '192.168.one.two'].each do |addr| + expect { + described_class.validate_ip_address!(addr) + }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}") + end + end + end + + context 'with empty or nil values' do + it 'accepts nil' do + expect(described_class.validate_ip_address!(nil)).to be true + end + + it 'accepts empty string' do + expect(described_class.validate_ip_address!('')).to be true + end + end + end + + describe '.validate_port_range!' do + context 'with valid single ports' do + it 'accepts port 1' do + expect(described_class.validate_port_range!(1)).to be true + end + + it 'accepts port 65535' do + expect(described_class.validate_port_range!(65535)).to be true + end + + it 'accepts port 80' do + expect(described_class.validate_port_range!(80)).to be true + end + end + + context 'with valid port ranges' do + it 'accepts string range' do + expect(described_class.validate_port_range!('1-1024')).to be true + end + + it 'accepts range with max ports' do + expect(described_class.validate_port_range!('1-65535')).to be true + end + end + + context 'with invalid ports' do + it 'rejects port 0' do + expect { + described_class.validate_port_range!(0) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: 0') + end + + it 'rejects port above 65535' do + expect { + described_class.validate_port_range!(65536) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: 65536') + end + + it 'rejects negative ports' do + expect { + described_class.validate_port_range!(-1) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: -1') + end + end + + context 'with invalid range formats' do + it 'rejects backwards range' do + expect { + described_class.validate_port_range!('100-50') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port range must be between 1 and 65535: 100..50') + end + + it 'rejects "1-"' do + expect { + described_class.validate_port_range!('1-') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be an integer: "1-"') + end + + it 'rejects "-100"' do + expect { + described_class.validate_port_range!('-100') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: -100') + end + + it 'rejects "abc-def"' do + expect { + described_class.validate_port_range!('abc-def') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Port range must have integer bounds: abc-def') + end + end + + context 'with empty or nil values' do + it 'accepts nil' do + expect(described_class.validate_port_range!(nil)).to be true + end + + it 'accepts empty string' do + expect(described_class.validate_port_range!('')).to be true + end + end + end + + describe '.validate_only_up!' do + context 'with valid boolean values' do + it 'accepts true' do + expect(described_class.validate_only_up!(true)).to be true + end + + it 'accepts false' do + expect(described_class.validate_only_up!(false)).to be true + end + end + + context 'with invalid values' do + it 'rejects string "true"' do + expect { + described_class.validate_only_up!('true') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: "true". Must be one of: true, false') + end + + it 'rejects string "false"' do + expect { + described_class.validate_only_up!('false') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: "false". Must be one of: true, false') + end + + it 'rejects nil' do + expect { + described_class.validate_only_up!(nil) + }.to raise_error(Msf::MCP::Security::ValidationError, 'only_up cannot be nil') + end + + it 'rejects integer 1' do + expect { + described_class.validate_only_up!(1) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: 1. Must be one of: true, false') + end + + it 'rejects integer 0' do + expect { + described_class.validate_only_up!(0) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: 0. Must be one of: true, false') + end + end + end + + describe '.validate_protocol!' do + context 'with valid protocols' do + it 'accepts tcp' do + expect(described_class.validate_protocol!('tcp')).to be true + end + + it 'accepts udp' do + expect(described_class.validate_protocol!('udp')).to be true + end + + it 'accepts TCP (uppercase)' do + expect(described_class.validate_protocol!('TCP')).to be true + end + + it 'accepts UDP (uppercase)' do + expect(described_class.validate_protocol!('UDP')).to be true + end + + it 'accepts nil' do + expect(described_class.validate_protocol!(nil)).to be true + end + + it 'accepts empty string' do + expect(described_class.validate_protocol!('')).to be true + end + end + + context 'with invalid protocols' do + it 'rejects icmp' do + expect { + described_class.validate_protocol!('icmp') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "icmp". Must be one of: tcp, udp') + end + + it 'rejects http' do + expect { + described_class.validate_protocol!('http') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "http". Must be one of: tcp, udp') + end + + it 'rejects random string' do + expect { + described_class.validate_protocol!('invalid') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "invalid". Must be one of: tcp, udp') + end + end + end + + describe '.validate_search_query!' do + context 'with valid search queries' do + it 'accepts normal search terms' do + expect(described_class.validate_search_query!('apache')).to be true + end + + it 'accepts search with spaces' do + expect(described_class.validate_search_query!('apache http')).to be true + end + + it 'accepts search with hyphens' do + expect(described_class.validate_search_query!('ms17-010')).to be true + end + end + + context 'with invalid search queries' do + it 'rejects empty string' do + expect { + described_class.validate_search_query!('') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query cannot be empty') + end + + it 'rejects nil' do + expect { + described_class.validate_search_query!(nil) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query cannot be nil') + end + + it 'rejects very long queries' do + long_query = 'a' * 501 + expect { + described_class.validate_search_query!(long_query) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query too long (max 500 characters)') + end + + it 'rejects non printable characters' do + expect { + described_class.validate_search_query!("bad\x15\x10") + }.to raise_error(Msf::MCP::Security::ValidationError, /^Invalid Search query format:/) + end + end + end + + describe '.validate_limit!' do + it 'accepts valid limit' do + expect(described_class.validate_limit!(100)).to be true + end + + it 'accepts minimum limit' do + expect(described_class.validate_limit!(1)).to be true + end + + it 'rejects zero' do + expect { + described_class.validate_limit!(0) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0') + end + + it 'rejects negative number' do + expect { + described_class.validate_limit!(-10) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: -10') + end + + it 'rejects excessive limit' do + expect { + described_class.validate_limit!(10001) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 10001') + end + + it 'accepts nil' do + expect(described_class.validate_limit!(nil)).to be true + end + + it 'accepts empty string' do + expect(described_class.validate_limit!('')).to be true + end + end + + describe '.validate_offset!' do + it 'accepts valid offset' do + expect(described_class.validate_offset!(100)).to be true + end + + it 'accepts zero offset' do + expect(described_class.validate_offset!(0)).to be true + end + + it 'rejects negative offset' do + expect { + described_class.validate_offset!(-10) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: -10') + end + + it 'rejects non-integer offset' do + expect { + described_class.validate_offset!('abc') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be an integer: "abc"') + end + + it 'rejects excessive offset' do + expect { + described_class.validate_offset!(10001) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: 10001') + end + end + + describe '.validate_pagination!' do + context 'with valid pagination parameters' do + it 'accepts valid limit and offset' do + expect { described_class.validate_pagination!(100, 50) }.not_to raise_error + end + + it 'accepts nil limit and offset' do + expect { described_class.validate_pagination!(nil, nil) }.not_to raise_error + end + + it 'accepts valid limit with nil offset' do + expect { described_class.validate_pagination!(50, nil) }.not_to raise_error + end + + it 'accepts nil limit with valid offset' do + expect { described_class.validate_pagination!(nil, 0) }.not_to raise_error + end + end + + context 'with invalid pagination parameters' do + it 'rejects invalid limit' do + expect { + described_class.validate_pagination!(0, 10) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0') + end + + it 'rejects invalid offset' do + expect { + described_class.validate_pagination!(10, -5) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: -5') + end + + it 'rejects both invalid parameters' do + expect { + described_class.validate_pagination!(0, -5) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0') + end + end + end + + describe '.validate_module_type!' do + context 'with valid module types' do + it 'accepts exploit' do + expect(described_class.validate_module_type!('exploit')).to be true + end + + it 'accepts auxiliary' do + expect(described_class.validate_module_type!('auxiliary')).to be true + end + + it 'accepts post' do + expect(described_class.validate_module_type!('post')).to be true + end + + it 'accepts payload' do + expect(described_class.validate_module_type!('payload')).to be true + end + + it 'accepts encoder' do + expect(described_class.validate_module_type!('encoder')).to be true + end + + it 'accepts evasion' do + expect(described_class.validate_module_type!('evasion')).to be true + end + + it 'accepts nop' do + expect(described_class.validate_module_type!('nop')).to be true + end + end + + context 'with invalid module types' do + it 'rejects invalid type' do + expect { + described_class.validate_module_type!('invalid') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "invalid". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop') + end + + it 'rejects uppercase type' do + expect { + described_class.validate_module_type!('EXPLOIT') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "EXPLOIT". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop') + end + + it 'rejects empty string' do + expect { + described_class.validate_module_type!('') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Module type cannot be empty') + end + + it 'rejects nil' do + expect { + described_class.validate_module_type!(nil) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Module type cannot be nil') + end + + it 'rejects scanner (not a valid type)' do + expect { + described_class.validate_module_type!('scanner') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "scanner". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop') + end + end + end + + describe '.validate_module_name!' do + context 'with valid module names' do + it 'accepts simple module name' do + expect(described_class.validate_module_name!('apache_exploit')).to be true + end + + it 'accepts module name with path' do + expect(described_class.validate_module_name!('exploit/windows/smb/ms17_010_eternalblue')).to be true + end + + it 'accepts module name with hyphens' do + expect(described_class.validate_module_name!('exploit/windows/ms17-010')).to be true + end + + it 'accepts module name with underscores' do + expect(described_class.validate_module_name!('auxiliary/scanner/http/wordpress_scanner')).to be true + end + + it 'accepts module name with numbers' do + expect(described_class.validate_module_name!('exploit/multi/http/struts2_rest_xstream')).to be true + end + end + + context 'with invalid module names' do + it 'rejects empty string' do + expect { + described_class.validate_module_name!('') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name cannot be empty') + end + + it 'rejects nil' do + expect { + described_class.validate_module_name!(nil) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name cannot be nil') + end + + it 'rejects whitespace-only string' do + expect { + described_class.validate_module_name!(' ') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: ') + end + + it 'rejects very long module names' do + long_name = 'a' * 501 + expect { + described_class.validate_module_name!(long_name) + }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name too long (max 500 characters)') + end + + it 'rejects module name with spaces' do + expect { + described_class.validate_module_name!('exploit/windows/my exploit') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/my exploit') + end + + it 'rejects module name with special characters' do + expect { + described_class.validate_module_name!('exploit/windows/test@exploit') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/test@exploit') + end + + it 'rejects module name with dots' do + expect { + described_class.validate_module_name!('exploit/windows/../etc/passwd') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/../etc/passwd') + end + + it 'rejects module name with backslashes' do + expect { + described_class.validate_module_name!('exploit\\windows\\test') + }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit\windows\test') + end + end + end + + describe 'fuzzing tests' do + it 'handles random IP-like strings' do + 1000.times do + random_ip = "#{rand(300)}.#{rand(300)}.#{rand(300)}.#{rand(300)}" + begin + described_class.validate_ip_address!(random_ip) + rescue Msf::MCP::Security::ValidationError + # Expected for invalid IPs + end + end + end + + it 'handles random port numbers' do + 1000.times do + random_port = rand(-100..70000) + begin + described_class.validate_port_range!(random_port) + rescue Msf::MCP::Security::ValidationError + # Expected for invalid ports + end + end + end + + it 'handles random strings with special characters' do + special_chars = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '[', ']', '{', '}', '|', '\\', '/', '<', '>'] + 100.times do + random_string = Array.new(rand(1..50)) { special_chars.sample }.join + begin + described_class.validate_search_query!(random_string) + rescue Msf::MCP::Security::ValidationError + # Expected for invalid queries + end + end + end + end +end diff --git a/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb b/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb new file mode 100644 index 0000000000000..1482c0e43438c --- /dev/null +++ b/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Security::RateLimiter do + describe '#initialize' do + it 'sets requests_per_minute' do + limiter = described_class.new(requests_per_minute: 30) + expect(limiter.instance_variable_get(:@requests_per_minute)).to eq(30) + end + + it 'sets burst_size to requests_per_minute by default' do + limiter = described_class.new(requests_per_minute: 30) + expect(limiter.instance_variable_get(:@burst_size)).to eq(30) + end + + it 'allows custom burst_size' do + limiter = described_class.new(requests_per_minute: 30, burst_size: 50) + expect(limiter.instance_variable_get(:@burst_size)).to eq(50) + end + + it 'initializes with full token bucket' do + limiter = described_class.new(requests_per_minute: 60) + expect(limiter.instance_variable_get(:@tokens)).to eq(60.0) + end + end + + describe '#check_rate_limit!' do + let(:limiter) { described_class.new(requests_per_minute: 60) } + + it 'allows request when tokens available' do + expect { limiter.check_rate_limit! }.not_to raise_error + end + + it 'consumes one token per request' do + initial_tokens = limiter.instance_variable_get(:@tokens) + new_tokens = limiter.check_rate_limit! + expect(new_tokens).to be < initial_tokens + end + + it 'allows multiple requests up to burst_size' do + limiter = described_class.new(requests_per_minute: 10) + + 10.times do + expect { limiter.check_rate_limit! }.not_to raise_error + end + end + + it 'raises RateLimitExceededError when tokens exhausted' do + limiter = described_class.new(requests_per_minute: 2) + + # Consume all tokens + 2.times { limiter.check_rate_limit! } + + # Next request should raise + expect { limiter.check_rate_limit! }.to raise_error( + Msf::MCP::Security::RateLimitExceededError + ) + end + + it 'includes retry_after in error' do + limiter = described_class.new(requests_per_minute: 60) + + # Exhaust tokens + 60.times { limiter.check_rate_limit! } + + expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError) do |error| + expect(error.retry_after).to be_a(Integer) + expect(error.retry_after).to be > 0 + end + end + + it 'returns true when successful' do + tokens = limiter.check_rate_limit! + expect(tokens).not_to be_nil + end + + it 'accepts optional tool_name parameter' do + expect { limiter.check_rate_limit!('test_tool') }.not_to raise_error + end + end + + describe 'token refill' do + it 'adds tokens based on elapsed time' do + start = Time.now + allow(Time).to receive(:now).and_return(start) + limiter = described_class.new(requests_per_minute: 60) + + # Consume all tokens + 60.times { limiter.check_rate_limit! } + + # Advance time by 2 seconds (should refill ~2 tokens at 1/sec) + allow(Time).to receive(:now).and_return(start + 2) + + # Should allow a request now + expect { limiter.check_rate_limit! }.not_to raise_error + end + + it 'caps tokens at burst_size' do + start = Time.now + allow(Time).to receive(:now).and_return(start) + limiter = described_class.new(requests_per_minute: 60, burst_size: 5) + + # Consume 1 token + limiter.check_rate_limit! + + # Advance time far enough to fully refill + allow(Time).to receive(:now).and_return(start + 600) + + # Should allow burst_size requests but not burst_size + 1 + 5.times { expect { limiter.check_rate_limit! }.not_to raise_error } + expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError) + end + + it 'refills proportionally to time elapsed' do + start = Time.now + allow(Time).to receive(:now).and_return(start) + limiter = described_class.new(requests_per_minute: 60) + + # Exhaust all tokens + 60.times { limiter.check_rate_limit! } + + # Advance by exactly 1 second (should add exactly 1 token at 60/min = 1/sec) + allow(Time).to receive(:now).and_return(start + 1) + + # Should allow exactly 1 request + expect { limiter.check_rate_limit! }.not_to raise_error + expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError) + end + end + + describe 'thread safety' do + it 'handles concurrent requests correctly' do + limiter = described_class.new(requests_per_minute: 100) + + # Try to make 100 requests concurrently + threads = 100.times.map do + Thread.new do + limiter.check_rate_limit! + rescue Msf::MCP::Security::RateLimitExceededError + # Some may be rate limited, that's ok + nil + end + end + + threads.each(&:join) + + # All tokens should be consumed + expect(limiter.instance_variable_get(:@tokens)).to be < 1.0 + end + + it 'does not allow more requests than burst_size concurrently' do + limiter = described_class.new(requests_per_minute: 10) + + success_count = 0 + mutex = Mutex.new + threads = 20.times.map do + Thread.new do + limiter.check_rate_limit! + mutex.synchronize { success_count += 1 } + rescue Msf::MCP::Security::RateLimitExceededError + # Expected for requests beyond burst_size + end + end + + threads.each(&:join) + + # Should have exactly 10 successful requests + expect(success_count).to eq(10) + end + end +end + +RSpec.describe Msf::MCP::Security::RateLimitExceededError do + describe '#initialize' do + it 'sets retry_after' do + error = described_class.new(5) + expect(error.retry_after).to eq(5) + end + + it 'sets error message' do + error = described_class.new(5) + expect(error.message).to include('Rate limit exceeded') + expect(error.message).to include('5 seconds') + end + end +end diff --git a/spec/lib/msf/core/mcp/server_spec.rb b/spec/lib/msf/core/mcp/server_spec.rb new file mode 100644 index 0000000000000..7a61d866d7726 --- /dev/null +++ b/spec/lib/msf/core/mcp/server_spec.rb @@ -0,0 +1,509 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Server do + let(:valid_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + port: 55553, + endpoint: '/api/', + user: 'test_user', + password: 'test_password' + }, + rate_limit: { + requests_per_minute: 60, + burst_size: 10 + } + } + end + + let(:mock_msf_client) do + instance_double(Msf::MCP::Metasploit::Client).tap do |client| + allow(client).to receive(:shutdown) + end + end + + let(:rate_limiter) do + Msf::MCP::Security::RateLimiter.new( + requests_per_minute: valid_config.dig(:rate_limit, :requests_per_minute) || 60, + burst_size: valid_config.dig(:rate_limit, :burst_size) + ) + end + + let(:mock_mcp_server) do + instance_double(::MCP::Server).tap do |server| + allow(server).to receive(:transport=) + end + end + + let(:mock_transport) do + instance_double(::MCP::Server::Transports::StdioTransport).tap do |transport| + allow(transport).to receive(:open) + end + end + + describe '#initialize' do + it 'initializes with required dependencies' do + # Mock the transport to prevent the server to actually start listening + transport = instance_double(MCP::Server::Transports::StdioTransport) + allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport) + allow(transport).to receive(:open) + + server = described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + mcp_server = server.start + + expect(mcp_server.server_context[:msf_client]).to eq(mock_msf_client) + expect(mcp_server.server_context[:rate_limiter]).to eq(rate_limiter) + end + + it 'creates MCP server with correct parameters' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + name: 'msfmcp', + version: Msf::MCP::Application::VERSION, + tools: be_an(Array), + server_context: be_a(Hash) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'registers all MCP tools' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + tools: array_including( + Msf::MCP::Tools::SearchModules, + Msf::MCP::Tools::ModuleInfo, + Msf::MCP::Tools::HostInfo, + Msf::MCP::Tools::ServiceInfo, + Msf::MCP::Tools::VulnerabilityInfo, + Msf::MCP::Tools::NoteInfo, + Msf::MCP::Tools::CredentialInfo, + Msf::MCP::Tools::LootInfo + ) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'creates server context with msf_client and rate_limiter' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'does not include config hash in server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_not_including(:config) + ) + ).and_return(mock_mcp_server) + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'passes a configuration object with around_request and exception_reporter' do + expect(::MCP::Server).to receive(:new) do |args| + config = args[:configuration] + expect(config).to be_a(::MCP::Configuration) + expect(config.around_request).to be_a(Proc) + expect(config.exception_reporter).to be_a(Proc) + mock_mcp_server + end + + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + end + + describe '#start' do + let(:server) do + allow(::MCP::Server).to receive(:new).and_return(mock_mcp_server) + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + context 'with stdio transport' do + it 'creates stdio transport' do + expect(::MCP::Server::Transports::StdioTransport).to receive(:new).with(mock_mcp_server).and_return(mock_transport) + + server.start(transport: :stdio) + end + + it 'opens the transport' do + allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(mock_transport) + + expect(mock_transport).to receive(:open) + + server.start(transport: :stdio) + end + + it 'defaults to stdio when no transport specified' do + expect(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(mock_transport) + + server.start + end + end + + context 'with http transport' do + let(:mock_http_transport) do + instance_double(::MCP::Server::Transports::StreamableHTTPTransport) + end + let(:puma_handler) { double('puma_handler') } + let(:rack_app) { double('rack_app') } + + before do + # Stub #require to prevent actually loading rack and rack/handler/puma + allow(server).to receive(:require).with('rack').and_return(true) + allow(server).to receive(:require).with('rack/handler/puma').and_return(true) + + stub_const('Rack::Handler::Puma', puma_handler) + stub_const('Rack::Builder', double('Rack::Builder')) + + allow(::MCP::Server::Transports::StreamableHTTPTransport).to receive(:new).and_return(mock_http_transport) + allow(Rack::Builder).to receive(:new).and_return(rack_app) + + allow(puma_handler).to receive(:run) + end + + it 'creates http transport' do + expect(::MCP::Server::Transports::StreamableHTTPTransport).to receive(:new).with(mock_mcp_server) + + server.start(transport: :http, port: 3000) + end + + it 'starts Puma server via Rack handler' do + expect(puma_handler).to receive(:run).with( + anything, # Rack app + hash_including( + Port: 3000, + Host: 'localhost' + ) + ) + + server.start(transport: :http, port: 3000) + end + + it 'allows custom port' do + expect(puma_handler).to receive(:run).with( + anything, + hash_including(Port: 8080) + ) + + server.start(transport: :http, port: 8080) + end + + it 'creates a Rack application' do + expect(puma_handler).to receive(:run) do |rack_app, options| + expect(rack_app).to be(rack_app) + expect(options).to include(Port: 3000, Host: 'localhost') + end + + server.start(transport: :http, port: 3000) + end + end + + context 'with invalid transport' do + it 'raises ArgumentError' do + expect { + server.start(transport: :websocket) + }.to raise_error(ArgumentError, /Unknown transport.*websocket/) + end + + it 'error message mentions valid transports' do + expect { + server.start(transport: :invalid) + }.to raise_error(ArgumentError, /stdio.*http/) + end + end + end + + describe '#shutdown' do + let(:server) do + allow(::MCP::Server).to receive(:new).and_return(mock_mcp_server) + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + + it 'shuts down the Metasploit client' do + expect(mock_msf_client).to receive(:shutdown) + + server.shutdown + end + + it 'handles nil msf_client gracefully' do + server.instance_variable_set(:@msf_client, nil) + + expect { server.shutdown }.not_to raise_error + end + + it 'clears mcp_server reference' do + server.shutdown + + expect(server.instance_variable_get(:@mcp_server)).to be_nil + end + + it 'can be called multiple times safely' do + expect { + server.shutdown + server.shutdown + }.not_to raise_error + end + end + + describe 'dependency injection' do + let(:server) do + # Create server with pre-authenticated client + described_class.new( + msf_client: mock_msf_client, + rate_limiter: rate_limiter + ) + end + let(:mcp_server) { server.start } + + before do + # Mock the transport to prevent the server to actually start listening + transport = instance_double(MCP::Server::Transports::StdioTransport) + allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport) + allow(transport).to receive(:open) + end + + it 'uses the provided authenticated client' do + # The provided client should be used + expect(mcp_server.server_context[:msf_client]).to eq(mock_msf_client) + expect(mcp_server.server_context[:msf_client].object_id).to eq(mock_msf_client.object_id) + end + + it 'passes the provided client to server_context' do + expect(::MCP::Server).to receive(:new).with( + hash_including( + server_context: hash_including( + msf_client: mock_msf_client + ) + ) + ).and_return(mock_mcp_server) + + server + end + + context 'with a custom rate limiter' do + let(:rate_limiter) do + Msf::MCP::Security::RateLimiter.new( + requests_per_minute: 120, + burst_size: 20 + ) + end + + it 'uses the provided rate_limiter' do + expect(mcp_server.server_context[:rate_limiter]).to eq(rate_limiter) + expect(mcp_server.server_context[:rate_limiter].instance_variable_get(:@requests_per_minute)).to eq(120) + expect(mcp_server.server_context[:rate_limiter].instance_variable_get(:@burst_size)).to eq(20) + end + end + end + + # Instrumentation and logging tests + describe 'instrumentation and logging' do + require 'tempfile' + require 'json' + + let(:log_file) { Tempfile.new(['test_log', '.log']).tap(&:close).path } + + before do + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + register_log_source( + Msf::MCP::LOG_SOURCE, + Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), + Rex::Logging::LEV_3 + ) + # Mock the transport to prevent the server to actually start listening + transport = instance_double(MCP::Server::Transports::StdioTransport) + allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport) + allow(transport).to receive(:open) + end + + after do + if log_source_registered?(Msf::MCP::LOG_SOURCE) + deregister_log_source(Msf::MCP::LOG_SOURCE) + end + File.delete(log_file) if File.exist?(log_file) + end + + # Helper: parse the last JSON log entry from the file + def last_log_entry + JSON.parse(File.read(log_file).strip.split("\n").last) + end + + let(:server) { described_class.new(msf_client: mock_msf_client, rate_limiter: rate_limiter) } + let(:mcp_server) { server.start } + + describe 'around_request' do + it 'is always configured as a Proc' do + expect(mcp_server.configuration.around_request).to be_a(Proc) + end + + it 'calls the request handler and returns its result' do + result = mcp_server.configuration.around_request.call({ tool_name: 'test' }) { { isError: false } } + expect(result).to eq({ isError: false }) + end + + it 'logs error tool calls with error severity' do + mcp_server.configuration.around_request.call( + { method: 'tools/call', tool_name: 'test_tool', error: 'tool_not_found' } + ) { nil } + entry = last_log_entry + + expect(entry['severity']).to eq('ERROR') + expect(entry['message']).to include('MCP Error: tool_not_found') + end + + it 'logs successful tool calls with info severity' do + mcp_server.configuration.around_request.call( + { method: 'tools/call', tool_name: 'search_modules' } + ) { { isError: false } } + entry = last_log_entry + + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('Tool call: search_modules') + end + + it 'logs isError results with error severity' do + mcp_server.configuration.around_request.call( + { method: 'tools/call', tool_name: 'test_tool' } + ) { { isError: true } } + entry = last_log_entry + + expect(entry['severity']).to eq('ERROR') + expect(entry['message']).to include('(ERROR)') + end + + it 'logs via ilog when result is nil and no error' do + mcp_server.configuration.around_request.call( + { method: 'tools/call', tool_name: 'test_tool' } + ) { nil } + entry = last_log_entry + + expect(entry['severity']).to eq('INFO') + expect(entry['message']).to include('Tool call: test_tool') + end + + it 'logs prompt calls' do + mcp_server.configuration.around_request.call( + { method: 'prompts/get', prompt_name: 'exploit_suggestion' } + ) { {} } + + expect(last_log_entry['message']).to include('Prompt call: exploit_suggestion') + end + + it 'logs resource calls' do + mcp_server.configuration.around_request.call( + { method: 'resources/read', resource_uri: 'msf://exploits/windows' } + ) { {} } + + expect(last_log_entry['message']).to include('Resource call: msf://exploits/windows') + end + + it 'logs generic method calls' do + mcp_server.configuration.around_request.call({ method: 'ping' }) { {} } + + expect(last_log_entry['message']).to include('Method call: ping') + end + + it 'logs fallback message when no specific key is present' do + mcp_server.configuration.around_request.call({}) { {} } + + expect(last_log_entry['message']).to include('MCP request') + end + end + + describe 'exception_reporter' do + it 'is always configured as a Proc' do + expect(mcp_server.configuration.exception_reporter).to be_a(Proc) + end + + it 'is a no-op when called with nil arguments' do + expect { mcp_server.configuration.exception_reporter.call(nil, nil) }.not_to raise_error + end + + it 'logs exceptions with error severity' do + mcp_server.configuration.exception_reporter.call( + StandardError.new('Something went wrong'), + { request: '{"method":"tools/call","params":{"name":"msf_search_modules"}}' } + ) + entry = last_log_entry + + expect(entry['severity']).to eq('ERROR') + expect(entry['message']).to include('Error during request processing') + end + + it 'extracts JSON-RPC method name from request context' do + mcp_server.configuration.exception_reporter.call( + StandardError.new('fail'), + { request: '{"method":"tools/call","params":{"name":"test"}}' } + ) + + expect(last_log_entry['message']).to include('(tools/call)') + end + + it 'logs notification context' do + mcp_server.configuration.exception_reporter.call( + RuntimeError.new('Notification failed'), + { notification: 'notifications/initialized' } + ) + entry = last_log_entry + + expect(entry['message']).to include('Error during notification processing') + expect(entry['message']).to include('notifications/initialized') + end + + it 'logs unknown context type' do + mcp_server.configuration.exception_reporter.call(StandardError.new('Unknown error'), {}) + + expect(last_log_entry['message']).to include('Error during unknown processing') + end + + it 'handles a non-JSON request value' do + mcp_server.configuration.exception_reporter.call( + StandardError.new('Parse error'), { request: 'not valid json' } + ) + + expect(last_log_entry['message']).to include('Error during request processing') + end + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb new file mode 100644 index 0000000000000..e4a229033f95a --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::CredentialInfo 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 + { + 'creds' => [ + { + 'user' => 'admin', + 'pass' => 'password123', + 'type' => 'password', + 'host' => '192.168.1.100', + 'sname' => 'smb', + 'port' => 445, + 'proto' => 'tcp', + 'updated_at' => 1640995200 + }, + { + 'user' => 'root', + 'pass' => 'toor', + 'type' => 'password', + 'host' => '192.168.1.101', + 'sname' => 'ssh', + 'port' => 22, + 'proto' => 'tcp', + 'updated_at' => 1609459300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_creds).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name from contract' do + expect(described_class.tool_name).to eq('msf_credential_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include('workspace') + end + + it 'supports pagination parameters' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:limit]).not_to be_nil + expect(properties[:offset]).not_to be_nil + end + end + + describe 'Response Structure' do + it 'returns credentials with username, type, source' do + # Validate output_schema defines credential fields + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:host]).to eq({ type: 'string' }) + expect(data_items[:port]).to eq({ type: 'integer' }) + expect(data_items[:protocol]).to eq({ type: 'string' }) + expect(data_items[:service_name]).to eq({ type: 'string' }) + expect(data_items[:user]).to eq({ type: 'string' }) + expect(data_items[:type]).to eq({ type: 'string' }) + expect(data_items[:updated_at]).to eq({ type: 'string' }) + 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('credential_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_creds).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_creds).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:host]).to eq('192.168.1.100') + expect(data.first[:user]).to eq('admin') + expect(data.first[:type]).to eq('password') + expect(data.first[:port]).to eq(445) + expect(data.first[:protocol]).to eq('tcp') + expect(data.first[:service_name]).to eq('smb') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_creds).and_return({ 'creds' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_creds) + .and_raise(Msf::MCP::Metasploit::APIError, 'Database error') + + 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 + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_creds).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/host_info_spec.rb b/spec/lib/msf/core/mcp/tools/host_info_spec.rb new file mode 100644 index 0000000000000..343fc8f574841 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/host_info_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::HostInfo 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 + { + 'hosts' => [ + { + 'address' => '192.168.1.100', + 'mac' => '00:11:22:33:44:55', + 'name' => 'testhost', + 'os_name' => 'Linux', + 'os_flavor' => 'Ubuntu', + 'state' => 'alive', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + }, + { + 'address' => '192.168.1.101', + 'mac' => '00:11:22:33:44:56', + 'name' => 'testhost2', + 'os_name' => 'Windows', + 'state' => 'alive', + 'created_at' => 1609459300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_hosts).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_host_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as optional parameter with default' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:workspace][:type]).to eq('string') + expect(properties[:workspace][:default]).to eq('default') + end + + it 'defines addresses as optional string parameter' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:addresses][:type]).to eq('string') + end + + it 'defines only_up as optional boolean parameter' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:only_up][:type]).to eq('boolean') + end + + it 'supports pagination with limit and offset' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:limit]).not_to be_nil + expect(properties[:offset]).not_to be_nil + end + end + + describe 'Output Schema' do + it 'returns hosts with IP, OS, MAC, timestamps' do + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:address]).to eq({ type: 'string' }) + expect(data_items[:mac_address]).to eq({ type: 'string' }) + expect(data_items[:hostname]).to eq({ type: 'string' }) + expect(data_items[:os_name]).to eq({ type: 'string' }) + expect(data_items[:os_flavor]).to eq({ type: 'string' }) + expect(data_items[:created_at]).to eq({ type: 'string' }) + expect(data_items[:updated_at]).to eq({ type: 'string' }) + end + + it 'includes workspace in metadata' do + metadata_properties = described_class.output_schema.schema[:properties][:metadata][:properties] + expect(metadata_properties[:workspace]).to eq({ type: 'string' }) + expect(metadata_properties[:query_time]).to eq({ type: 'number' }) + expect(metadata_properties[:total_items]).to eq({ type: 'integer' }) + expect(metadata_properties[:returned_items]).to eq({ type: 'integer' }) + 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('host_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_hosts).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_hosts).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:address]).to eq('192.168.1.100') + expect(data.first[:hostname]).to eq('testhost') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].first[:address]).to eq('192.168.1.101') + end + + it 'passes addresses filter to MSF client' do + described_class.call(addresses: '192.168.1.0/24', server_context: server_context) + expect(msf_client).to have_received(:db_hosts).with(hash_including(addresses: '192.168.1.0/24')) + end + + it 'passes only_up filter to MSF client' do + described_class.call(only_up: true, server_context: server_context) + expect(msf_client).to have_received(:db_hosts).with(hash_including(only_up: true)) + end + + it 'validates limit parameter' do + result = described_class.call(limit: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/limit/i) + end + + it 'validates offset parameter' do + result = described_class.call(offset: -1, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/offset/i) + end + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_hosts).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_hosts).and_raise( + Msf::MCP::Metasploit::APIError.new('Server error') + ) + + 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 + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_hosts).and_return({ 'hosts' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + expect(result.structured_content[:data]).to eq([]) + end + + it 'returns content array with text representation' do + result = described_class.call(server_context: server_context) + + expect(result.content).to be_an(Array) + expect(result.content.first[:type]).to eq('text') + expect(result.content.first[:text]).to be_a(String) + + parsed = JSON.parse(result.content.first[:text]) + expect(parsed['metadata']).to be_a(Hash) + expect(parsed['data']).to be_an(Array) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb new file mode 100644 index 0000000000000..73032ec523e7b --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::LootInfo 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 + { + 'loots' => [ + { + 'id' => 1, + 'host' => '192.168.1.100', + 'service' => 'ssh', + 'ltype' => 'credentials', + 'path' => '/path/to/loot', + 'ctype' => 'text/plain', + 'name' => 'passwords.txt', + 'info' => 'Collected passwords', + 'data' => 'some data', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + }, + { + 'id' => 2, + 'host' => '192.168.1.101', + 'service' => 'http', + 'ltype' => 'file', + 'path' => '/path/to/file', + 'ctype' => 'application/octet-stream', + 'name' => 'upload.exe', + 'info' => 'Downloaded file', + 'data' => 'file data', + 'created_at' => 1609459300, + 'updated_at' => 1640995300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_loot).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_loot_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include('workspace') + end + + it 'supports pagination parameters' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:limit]).not_to be_nil + expect(properties[:offset]).not_to be_nil + end + end + + describe 'Output Schema' do + it 'returns loot with type, name, content_type, size' do + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:host]).to eq({ type: 'string' }) + expect(data_items[:service_name_or_port]).to eq({ type: 'string' }) + expect(data_items[:loot_type]).to eq({ type: 'string' }) + expect(data_items[:content_type]).to eq({ type: 'string' }) + expect(data_items[:name]).to eq({ type: 'string' }) + expect(data_items[:info]).to eq({ type: 'string' }) + expect(data_items[:data]).to eq({ type: 'string' }) + expect(data_items[:created_at]).to eq({ type: 'string' }) + expect(data_items[:updated_at]).to eq({ type: 'string' }) + 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('loot_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_loot).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_loot).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:host]).to eq('192.168.1.100') + expect(data.first[:loot_type]).to eq('credentials') + expect(data.first[:content_type]).to eq('text/plain') + expect(data.first[:name]).to eq('passwords.txt') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_loot).and_return({ 'loots' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_loot) + .and_raise(Msf::MCP::Metasploit::APIError, 'Database error') + + 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 + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_loot).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'passes pagination parameters to MSF client' do + # Pagination is applied client-side, not in the API call + described_class.call(limit: 50, offset: 10, server_context: server_context) + + # API call only receives workspace parameter + expect(msf_client).to have_received(:db_loot).with( + hash_including(workspace: 'default') + ) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/module_info_spec.rb b/spec/lib/msf/core/mcp/tools/module_info_spec.rb new file mode 100644 index 0000000000000..2eb13e596f4f9 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/module_info_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ModuleInfo 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 + { + 'name' => 'ms17_010_eternalblue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'type' => 'exploit', + 'rank' => 'excellent', + 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption', + 'license' => 'Metasploit Framework License (BSD)', + 'filepath' => '/usr/share/metasploit-framework/modules/exploits/windows/smb/ms17_010_eternalblue.rb', + 'arch' => ['x86', 'x64'], + 'platform' => ['Windows'], + 'authors' => ['sleepya', 'zerosum0x0'], + 'references' => [ + ['CVE', '2017-0143'], + ['MSB', 'MS17-010'] + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:module_info).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_info') + end + end + + describe 'Input Schema Validation' do + it 'defines type and name as required parameters' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include("type", "name") + end + + it 'defines type as enum with valid module types' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:type][:type]).to eq('string') + expect(properties[:type][:enum]).to include('exploit', 'auxiliary', 'post', 'payload') + end + + it 'defines name as string type' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:name][:type]).to eq('string') + end + end + + describe 'Output Schema' do + it 'returns complete module details' do + output_schema = described_class.output_schema.schema + data_properties = output_schema[:properties][:data][:properties] + + expect(data_properties[:type]).to eq({ type: 'string' }) + expect(data_properties[:name]).to eq({ type: 'string' }) + expect(data_properties[:fullname]).to eq({ type: 'string' }) + expect(data_properties[:description]).to eq({ type: 'string' }) + expect(data_properties[:rank]).to eq({ type: 'string' }) + expect(data_properties[:authors]).to eq({ type: 'array', items: { type: 'string' } }) + expect(data_properties[:platforms]).to eq({ type: 'array', items: { type: 'string' } }) + expect(data_properties[:architectures]).to eq({ type: 'array', items: { type: 'string', enum: %w[ + x86 x86_64 x64 mips mipsle mipsbe mips64 mips64le ppc ppce500v2 + ppc64 ppc64le cbea cbea64 sparc sparc64 armle armbe aarch64 cmd + php tty java ruby dalvik python nodejs firefox zarch r + riscv32be riscv32le riscv64be riscv64le loongarch64 + ] } }) + end + + it 'includes options object with configuration parameters' do + data_properties = described_class.output_schema.schema[:properties][:data][:properties] + expect(data_properties[:options]).to eq({ type: 'object' }) + expect(data_properties[:default_options]).to eq({ type: 'object' }) + end + + it 'includes targets object for exploit modules' do + data_properties = described_class.output_schema.schema[:properties][:data][:properties] + expect(data_properties[:targets]).to eq({ type: 'object' }) + expect(data_properties[:default_target]).to eq({ type: 'integer' }) + end + + it 'includes references array with CVE, MSB, URL refs' do + data_properties = described_class.output_schema.schema[:properties][:data][:properties] + expect(data_properties[:references]).to eq({ type: 'array', items: { type: ['string', 'object'] } }) + end + end + + describe '.call' do + it 'checks rate limit' do + described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('module_info') + end + + it 'calls Metasploit client with module type and name' do + described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context) + expect(msf_client).to have_received(:module_info).with('exploit', 'windows/smb/ms17_010_eternalblue') + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:query_time]).to be_a(Float) + end + + it 'includes transformed data in response' do + result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_a(Hash) + expect(data[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue') + expect(data[:type]).to eq('exploit') + end + + it 'validates module type' do + result = described_class.call(type: 'invalid', name: 'test', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/type/i) + end + + it 'validates module name' do + result = described_class.call(type: 'exploit', name: '', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/name/i) + end + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:module_info).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(type: 'exploit', name: 'test', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:module_info).and_raise( + Msf::MCP::Metasploit::APIError.new('Server error') + ) + + result = described_class.call(type: 'exploit', name: 'test', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(type: 'exploit', name: 'test', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + 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 new file mode 100644 index 0000000000000..0f1a4f576472b --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/note_info_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::NoteInfo 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 + { + 'notes' => [ + { + 'id' => 1, + 'host' => '192.168.1.100', + 'service' => 'https', + 'port' => 443, + 'protocol' => 'tcp', + 'ntype' => 'ssl.certificate', + 'data' => { + 'cn' => 'example.com', + 'issuer' => 'Let\'s Encrypt', + 'expiration' => '2024-12-31' + }, + 'critical' => false, + 'seen' => false, + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + }, + { + 'id' => 2, + 'host' => '192.168.1.101', + 'service' => 'smb', + 'port' => 445, + 'protocol' => 'tcp', + 'ntype' => 'smb.fingerprint', + 'data' => { + 'os' => 'Windows Server 2019', + 'version' => '10.0' + }, + 'critical' => false, + 'seen' => true, + 'created_at' => 1609459300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_notes).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_note_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include('workspace') + end + end + + describe 'Output Schema' do + it 'returns notes with type, content, timestamps' do + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:host]).to eq({ type: 'string' }) + expect(data_items[:service_name_or_port]).to eq({ type: 'string' }) + expect(data_items[:note_type]).to eq({ type: 'string' }) + expect(data_items[:data]).to eq({ type: 'string' }) + expect(data_items[:created_at]).to eq({ type: 'string' }) + 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('note_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_notes).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_notes).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:host]).to eq('192.168.1.100') + expect(data.first[:note_type]).to eq('ssl.certificate') + expect(data.first[:service_name_or_port]).to eq('https') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles filtering by type' do + described_class.call(type: 'ssl.certificate', server_context: server_context) + + expect(msf_client).to have_received(:db_notes).with( + hash_including(ntype: 'ssl.certificate') + ) + end + + it 'handles filtering by host' do + described_class.call(host: '192.168.1.100', server_context: server_context) + + expect(msf_client).to have_received(:db_notes).with( + hash_including(address: '192.168.1.100') + ) + end + + it 'handles filtering by ports' do + described_class.call(ports: '443', server_context: server_context) + + expect(msf_client).to have_received(:db_notes).with( + hash_including(ports: '443') + ) + end + + it 'handles filtering by protocol' do + described_class.call(protocol: 'tcp', server_context: server_context) + + expect(msf_client).to have_received(:db_notes).with( + hash_including(proto: 'tcp') + ) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_notes).and_return({ 'notes' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_notes) + .and_raise(Msf::MCP::Metasploit::APIError, 'Database error') + + 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 + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_notes).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'passes pagination parameters to MSF client' do + # Pagination is applied client-side, not in the API call + described_class.call(limit: 50, offset: 10, server_context: server_context) + + # API call only receives workspace parameter + expect(msf_client).to have_received(:db_notes).with( + hash_including(workspace: 'default') + ) + end + end +end diff --git a/spec/lib/msf/core/mcp/tools/search_modules_spec.rb b/spec/lib/msf/core/mcp/tools/search_modules_spec.rb new file mode 100644 index 0000000000000..cd59e9013a54f --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/search_modules_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::SearchModules 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 + [ + { + 'name' => 'ms17_010_eternalblue', + 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue', + 'type' => 'exploit', + 'rank' => 'excellent', + 'disclosuredate' => '2017-03-14', + 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption' + }, + { + 'name' => 'smb_version', + 'fullname' => 'auxiliary/scanner/smb/smb_version', + 'type' => 'auxiliary', + 'rank' => 'normal', + 'description' => 'SMB Version Detection' + } + ] + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:search_modules).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_search_modules') + end + end + + describe 'Input Schema Validation' do + it 'defines query as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include("query") + end + + it 'defines query as string type with length constraints' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:query][:type]).to eq('string') + expect(properties[:query][:minLength]).to eq(1) + expect(properties[:query][:maxLength]).to eq(500) + end + + it 'defines limit as optional integer with constraints' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:limit][:type]).to eq('integer') + expect(properties[:limit][:minimum]).to eq(1) + expect(properties[:limit][:maximum]).to eq(1000) + expect(properties[:limit][:default]).to eq(100) + end + + it 'defines offset as optional integer' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:offset][:type]).to eq('integer') + expect(properties[:offset][:minimum]).to eq(0) + expect(properties[:offset][:default]).to eq(0) + end + end + + describe 'Output Schema' do + it 'returns response with metadata and data keys' do + output_schema = described_class.output_schema.schema + expect(output_schema[:required]).to include('metadata', 'data') + expect(output_schema[:properties][:metadata]).to be_a(Hash) + expect(output_schema[:properties][:data]).to be_a(Hash) + end + + it 'metadata includes query, query_time, total_items, and pagination' do + properties = described_class.output_schema.schema[:properties][:metadata][:properties] + expect(properties[:query]).to eq({ type: 'string' }) + expect(properties[:query_time]).to eq({ type: 'number' }) + expect(properties[:total_items]).to eq({ type: 'integer' }) + expect(properties[:returned_items]).to eq({ type: 'integer' }) + expect(properties[:limit]).to eq({ type: 'integer' }) + expect(properties[:offset]).to eq({ type: 'integer' }) + end + + it 'data array contains modules with required fields' do + data_schema = described_class.output_schema.schema[:properties][:data] + expect(data_schema[:type]).to eq('array') + expect(data_schema[:items]).to be_a(Hash) + expect(data_schema[:items][:properties]).to be_a(Hash) + end + + it 'each module has fullname, type, and name as required fields' do + item_properties = described_class.output_schema.schema[:properties][:data][:items][:properties] + expect(item_properties[:fullname]).to eq({ type: 'string' }) + expect(item_properties[:type]).to eq({ type: 'string' }) + expect(item_properties[:name]).to eq({ type: 'string' }) + expect(item_properties[:rank]).to eq({ type: 'string' }) + expect(item_properties[:disclosure_date]).to eq({ type: 'string' }) + end + + it 'module type is one of the allowed enum values' do + item_properties = described_class.output_schema.schema[:properties][:data][:items][:properties] + expect(item_properties[:type][:type]).to eq('string') + end + end + + describe '.call' do + it 'validates search query' do + result = described_class.call(query: '', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/query/i) + end + + it 'checks rate limit' do + described_class.call(query: 'smb', server_context: server_context) + expect(rate_limiter).to have_received(:check_rate_limit!).with('search_modules') + end + + it 'calls Metasploit client with query' do + described_class.call(query: 'smb windows', server_context: server_context) + expect(msf_client).to have_received(:search_modules).with('smb windows') + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(query: 'smb', server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(query: 'smb', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:query]).to eq('smb') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(query: 'smb', server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue') + end + + it 'handles pagination with limit' do + result = described_class.call(query: 'smb', limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(query: 'smb', offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].first[:fullname]).to eq('auxiliary/scanner/smb/smb_version') + end + + it 'validates limit parameter' do + result = described_class.call(query: 'smb', limit: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/limit/i) + end + + it 'validates offset parameter' do + result = described_class.call(query: 'smb', offset: -1, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/offset/i) + end + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:search_modules).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(query: 'smb', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:search_modules).and_raise( + Msf::MCP::Metasploit::APIError.new('Server error') + ) + + result = described_class.call(query: 'smb', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Metasploit API error/) + end + + it 'returns error response for validation errors' do + allow(rate_limiter).to receive(:check_rate_limit!).and_raise( + Msf::MCP::Security::ValidationError.new('Rate limit exceeded') + ) + + result = described_class.call(query: 'smb', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(query: 'smb', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'handles empty search results' do + allow(msf_client).to receive(:search_modules).and_return([]) + + result = described_class.call(query: 'nonexistent', server_context: server_context) + + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + expect(result.structured_content[:data]).to eq([]) + end + + it 'returns content array with text representation' do + result = described_class.call(query: 'smb', server_context: server_context) + + expect(result.content).to be_an(Array) + expect(result.content.first[:type]).to eq('text') + expect(result.content.first[:text]).to be_a(String) + + parsed = JSON.parse(result.content.first[:text]) + expect(parsed['metadata']).to be_a(Hash) + expect(parsed['data']).to be_an(Array) + 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 new file mode 100644 index 0000000000000..530a83c09348a --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/service_info_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ServiceInfo 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 + { + 'services' => [ + { + 'host' => '192.168.1.100', + 'port' => 80, + 'proto' => 'tcp', + 'state' => 'open', + 'name' => 'http', + 'info' => 'Apache httpd 2.4.41', + 'created_at' => 1609459200, + 'updated_at' => 1640995200 + }, + { + 'host' => '192.168.1.100', + 'port' => 443, + 'proto' => 'tcp', + 'state' => 'open', + 'name' => 'https', + 'info' => 'Apache httpd 2.4.41 (SSL)', + 'created_at' => 1609459300, + 'updated_at' => 1640995300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_services).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_service_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include('workspace') + end + + it 'supports multiple filter parameters' do + properties = described_class.input_schema.schema[:properties] + expect(properties[:host]).not_to be_nil + expect(properties[:ports]).not_to be_nil + expect(properties[:protocol]).not_to be_nil + expect(properties[:names]).not_to be_nil + expect(properties[:only_up]).not_to be_nil + end + end + + describe 'Output Schema' do + it 'returns services with port, protocol, service_name' do + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:port]).to eq({ type: 'integer' }) + expect(data_items[:protocol]).to eq({ type: 'string' }) + expect(data_items[:name]).to eq({ type: 'string' }) + expect(data_items[:host_address]).to eq({ type: 'string' }) + expect(data_items[:state]).to eq({ type: 'string' }) + expect(data_items[:info]).to eq({ type: 'string' }) + 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('service_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:host_address]).to eq('192.168.1.100') + expect(data.first[:port]).to eq(80) + expect(data.first[:protocol]).to eq('tcp') + expect(data.first[:name]).to eq('http') + expect(data.first[:state]).to eq('open') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].first[:port]).to eq(443) + end + + it 'passes host filter to MSF client' do + described_class.call(host: '192.168.1.100', server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(addresses: '192.168.1.100')) + end + + it 'passes ports filter to MSF client' do + described_class.call(ports: '80', server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(ports: '80')) + end + + it 'passes protocol filter to MSF client' do + described_class.call(protocol: 'tcp', server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(proto: 'tcp')) + end + + it 'passes names filter to MSF client' do + described_class.call(names: 'http,https', server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(names: 'http,https')) + end + + it 'passes only_up filter to MSF client' do + described_class.call(only_up: true, server_context: server_context) + expect(msf_client).to have_received(:db_services).with(hash_including(only_up: true)) + end + + it 'validates limit parameter' do + result = described_class.call(limit: 0, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/limit/i) + end + + it 'validates offset parameter' do + result = described_class.call(offset: -1, server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/offset/i) + end + + it 'validates protocol parameter' do + result = described_class.call(protocol: 'invalid', server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/protocol/i) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_services).and_return({ 'services' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_services).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_services).and_raise( + Msf::MCP::Metasploit::APIError.new('Server error') + ) + + 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 + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns content array with text representation' do + result = described_class.call(server_context: server_context) + + expect(result.content).to be_an(Array) + expect(result.content.first[:type]).to eq('text') + expect(result.content.first[:text]).to be_a(String) + + parsed = JSON.parse(result.content.first[:text]) + expect(parsed['metadata']).to be_a(Hash) + expect(parsed['data']).to be_an(Array) + 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 new file mode 100644 index 0000000000000..5c118a4566e08 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::ToolHelper do + # Create a test class that includes the helper inside class << self, + # mirroring how the actual tools use it. + let(:tool_class) do + mod = described_class + Class.new do + class << self + include Msf::MCP::Tools::ToolHelper + end + end + end + + describe '#tool_error_response' do + it 'returns an MCP::Tool::Response' do + result = tool_class.tool_error_response('Something went wrong') + expect(result).to be_a(::MCP::Tool::Response) + end + + it 'sets the error flag to true' do + result = tool_class.tool_error_response('Something went wrong') + expect(result.error?).to be true + end + + it 'includes the error message in the content' do + result = tool_class.tool_error_response('Something went wrong') + expect(result.content).to eq([{ type: 'text', text: 'Something went wrong' }]) + end + + it 'preserves the full message for authentication errors' do + result = tool_class.tool_error_response('Authentication failed: Invalid token') + expect(result.content.first[:text]).to eq('Authentication failed: Invalid token') + expect(result.error?).to be true + end + + it 'preserves the full message for API errors' do + result = tool_class.tool_error_response('Metasploit API error: Server error') + expect(result.content.first[:text]).to eq('Metasploit API error: Server error') + expect(result.error?).to be true + end + + it 'preserves the full message for rate limit errors' do + result = tool_class.tool_error_response('Rate limit exceeded: Retry after 5 seconds.') + expect(result.content.first[:text]).to eq('Rate limit exceeded: Retry after 5 seconds.') + expect(result.error?).to be true + 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 new file mode 100644 index 0000000000000..ec99f0aa9b1f5 --- /dev/null +++ b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'msf/core/mcp' + +RSpec.describe Msf::MCP::Tools::VulnerabilityInfo 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 + { + 'vulns' => [ + { + 'host' => '192.168.1.100', + 'port' => 445, + 'proto' => 'tcp', + 'name' => 'MS17-010', + 'refs' => 'CVE-2017-0144,MS17-010', + 'time' => 1609459200 + }, + { + 'host' => '192.168.1.101', + 'port' => 80, + 'proto' => 'tcp', + 'name' => 'Apache Struts RCE', + 'refs' => 'CVE-2017-5638', + 'time' => 1609459300 + } + ] + } + end + + before do + allow(rate_limiter).to receive(:check_rate_limit!) + allow(msf_client).to receive(:db_vulns).and_return(msf_response) + end + + describe 'Tool Name' do + it 'has the correct tool name' do + expect(described_class.tool_name).to eq('msf_vulnerability_info') + end + end + + describe 'Input Schema Validation' do + it 'defines workspace as required parameter' do + input_schema = described_class.input_schema + expect(input_schema.schema[:required]).to include('workspace') + end + end + + describe 'Output Schema' do + it 'returns vulnerabilities with CVE, severity, exploit_available' do + data_items = described_class.output_schema.schema[:properties][:data][:items][:properties] + + expect(data_items[:host]).to eq({ type: 'string' }) + expect(data_items[:port]).to eq({ type: 'integer' }) + expect(data_items[:protocol]).to eq({ type: 'string' }) + expect(data_items[:name]).to eq({ type: 'string' }) + expect(data_items[:references]).to eq({ type: 'array', items: { type: 'string' } }) + expect(data_items[:created_at]).to eq({ type: 'string' }) + 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('vulnerability_info') + end + + it 'calls Metasploit client with workspace' do + described_class.call(workspace: 'test_ws', server_context: server_context) + expect(msf_client).to have_received(:db_vulns).with(hash_including(workspace: 'test_ws')) + end + + it 'uses default workspace' do + described_class.call(server_context: server_context) + expect(msf_client).to have_received(:db_vulns).with(hash_including(workspace: 'default')) + end + + it 'returns MCP::Tool::Response' do + result = described_class.call(server_context: server_context) + expect(result).to be_a(MCP::Tool::Response) + end + + it 'includes metadata in response' do + result = described_class.call(workspace: 'default', server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:workspace]).to eq('default') + expect(metadata[:query_time]).to be_a(Float) + expect(metadata[:total_items]).to eq(2) + expect(metadata[:returned_items]).to eq(2) + expect(metadata[:limit]).to eq(100) + expect(metadata[:offset]).to eq(0) + end + + it 'includes transformed data in response' do + result = described_class.call(server_context: server_context) + + data = result.structured_content[:data] + expect(data).to be_an(Array) + expect(data.length).to eq(2) + expect(data.first[:host]).to eq('192.168.1.100') + expect(data.first[:name]).to eq('MS17-010') + expect(data.first[:port]).to eq(445) + expect(data.first[:protocol]).to eq('tcp') + end + + it 'handles pagination with limit' do + result = described_class.call(limit: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:limit]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles pagination with offset' do + result = described_class.call(offset: 1, server_context: server_context) + + metadata = result.structured_content[:metadata] + expect(metadata[:offset]).to eq(1) + expect(metadata[:returned_items]).to eq(1) + expect(result.structured_content[:data].length).to eq(1) + end + + it 'handles filtering by names' do + described_class.call(names: ['MS17-010'], server_context: server_context) + + # Names array is joined with commas before being sent to API + expect(msf_client).to have_received(:db_vulns).with( + hash_including(names: 'MS17-010') + ) + end + + it 'handles filtering by host' do + described_class.call(host: '192.168.1.100', server_context: server_context) + + expect(msf_client).to have_received(:db_vulns).with( + hash_including(address: '192.168.1.100') + ) + end + + it 'handles filtering by ports' do + described_class.call(ports: '445', server_context: server_context) + + expect(msf_client).to have_received(:db_vulns).with( + hash_including(ports: '445') + ) + end + + it 'handles filtering by protocol' do + described_class.call(protocol: 'tcp', server_context: server_context) + + expect(msf_client).to have_received(:db_vulns).with( + hash_including(proto: 'tcp') + ) + end + + it 'handles empty results' do + allow(msf_client).to receive(:db_vulns).and_return({ 'vulns' => [] }) + + result = described_class.call(server_context: server_context) + + expect(result.structured_content[:data]).to eq([]) + expect(result.structured_content[:metadata][:total_items]).to eq(0) + expect(result.structured_content[:metadata][:returned_items]).to eq(0) + end + + it 'returns error response for rate limit exceeded' do + allow(rate_limiter).to receive(:check_rate_limit!) + .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60)) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Rate limit exceeded/) + end + + it 'returns error response for API errors' do + allow(msf_client).to receive(:db_vulns) + .and_raise(Msf::MCP::Metasploit::APIError, 'Database error') + + 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 + + it 'returns error response for authentication errors' do + allow(msf_client).to receive(:db_vulns).and_raise( + Msf::MCP::Metasploit::AuthenticationError.new('Invalid token') + ) + + result = described_class.call(server_context: server_context) + expect(result.error?).to be true + expect(result.content.first[:text]).to match(/Authentication failed/) + end + + it 'passes pagination parameters to MSF client' do + # Pagination is applied client-side, not in the API call + described_class.call(limit: 50, offset: 10, server_context: server_context) + + # API call only receives workspace parameter + expect(msf_client).to have_received(:db_vulns).with( + hash_including(workspace: 'default') + ) + end + + it 'handles multiple name filters' do + names = ['MS17-010', 'Apache Struts RCE'] + described_class.call(names: names, server_context: server_context) + + # Names array is joined with commas before being sent to API + expect(msf_client).to have_received(:db_vulns).with( + hash_including(names: 'MS17-010,Apache Struts RCE') + ) + end + end +end