diff --git a/analyzer/windows/analyzer.py b/analyzer/windows/analyzer.py index 9778336417c..410f92af746 100644 --- a/analyzer/windows/analyzer.py +++ b/analyzer/windows/analyzer.py @@ -351,6 +351,49 @@ def complete(self): log.info("Analysis completed") + def handle_reboot(self): + """Handle system reboot request.""" + # We need to persist the analyzer so it runs again after reboot. + # We will use the RunOnce registry key. + + # 1. Determine paths + python_path = sys.executable + analyzer_path = os.path.abspath(sys.argv[0]) + working_dir = os.getcwd() + + # 2. Formulate command + # We use cmd.exe to ensure the working directory is correct. + # cmd /c "cd /d && " + command = 'cmd /c "cd /d "{}" && "{}" "{}"'.format(working_dir, python_path, analyzer_path) + + # 3. Write to Registry + from lib.common.registry import set_regkey_full + from lib.common.rand import random_string + + # Randomize the key name to avoid detection + key_name = random_string(8) + + # Determine root key based on privileges + if SHELL32.IsUserAnAdmin(): + key_path = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\RunOnce\\{}".format(key_name) + else: + key_path = "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce\\{}".format(key_name) + + log.info("Setting reboot persistence: %s -> %s", key_path, command) + try: + set_regkey_full(key_path, "REG_SZ", command) + except Exception as e: + log.error("Failed to set persistence key: %s", e) + return + + # 4. Initiate Reboot + log.info("Initiating system reboot") + # Using shutdown command is robust + subprocess.run(["shutdown", "/r", "/t", "0", "/f"], check=False) + + # Stop the analysis loop so we don't interfere while shutting down + self.do_run = False + def get_completion_key(self): return getattr(self.config, "completion_key", "") @@ -1260,6 +1303,11 @@ def _handle_resume(self, data): self.analyzer.LASTINJECT_TIME = timeit.default_timer() self._handle_process(data) + def _handle_reboot(self, data): + """Handle reboot request from the monitor.""" + log.info("Received reboot request from monitored process") + self.analyzer.handle_reboot() + def _handle_shutdown(self, data): """Handle attempted shutdowns/restarts. diff --git a/conf/default/api.conf.default b/conf/default/api.conf.default index a86be5729d8..22b023aabff 100644 --- a/conf/default/api.conf.default +++ b/conf/default/api.conf.default @@ -12,9 +12,11 @@ url = http://example.tld # APIv2 - Enable token autorization? User limit settings at http://127.0.0.1:8000/admin/auth/user/ token_auth_enabled = no +mcp = no [list_exitnodes] enabled = no +mcp = no # Details about N of tasks(reported/failed) and time spend on each module [statistics] @@ -22,18 +24,21 @@ enabled = no auth_only = no rps = 1/s rpm = 5/m +mcp = no # Download any file from CAPE webgui [download_file] enabled = yes rps = 1/s rpm = 5/m +mcp = no [filereport] enabled = yes auth_only = no rps = 1/s rpm = 5/m +mcp = no # Submit Download & execute - category tasks to Cuckoo. [dlnexeccreate] @@ -43,12 +48,14 @@ allmachines = no status = yes rps = 2/s rpm = 2/m +mcp = no [staticextraction] enabled = yes auth_only = no rps = 5/s rpm = 5/m +mcp = no # Submit file-category tasks to Cuckoo. @@ -65,6 +72,7 @@ status = yes # Configuration variables for requests per minute and requests per second. rps = 1/s rpm = 2/m +mcp = no # Submit URL-category tasks to Cuckoo. [urlcreate] @@ -74,6 +82,7 @@ allmachines = no status = yes rps = 1/s rpm = 2/m +mcp = no [downloading_services] enabled = no @@ -82,6 +91,7 @@ allmachines = no status = yes rps = 1/s rpm = 2/m +mcp = no # Pull sample information from the Sample table. [fileview] @@ -95,12 +105,14 @@ sha256 = yes id = yes rps = 2/s rpm = 10/m +mcp = no [web_search] enabled = yes auth_only = no rps = 2/s rpm = 10/m +mcp = no # Pull Task ID's by searching for a hash. [tasksearch] @@ -113,6 +125,7 @@ sha1 = yes sha256 = yes rps = 2/s rpm = 10/m +mcp = no # Pull Task ID's using the same search functions as Django's search. [extendedtasksearch] @@ -120,6 +133,7 @@ enabled = yes auth_only = no rps = 1/s rpm = 10/m +mcp = no # Pull Task information for a range of Task ID's. # Note: Pulling a large amount of Task ID's can produce slow responses. @@ -132,6 +146,7 @@ maxlimit = 50 defaultlimit = 10 rps = 1/s rpm = 5/m +mcp = no # Pull task, sample, guest, and error information from a specific Task ID. [taskview] @@ -139,6 +154,7 @@ enabled = yes auth_only = no rps = 1/s rpm = 10/m +mcp = no # Configure the ability reschedule a broken task. [taskresched] @@ -146,6 +162,7 @@ enabled = no auth_only = no rps = 1/s rpm = 5/m +mcp = no # Configure the ability to reprocess a task. [taskreprocess] @@ -153,6 +170,7 @@ enabled = no auth_only = no rps = 1/s rpm = 5/m +mcp = no # Configure the ability to delete a specified Task ID. [taskdelete] @@ -160,6 +178,7 @@ enabled = no auth_only = no rps = 1/s rpm = 5/m +mcp = no # Configure the ability to poll for a task status [taskstatus] @@ -167,6 +186,7 @@ enabled = yes auth_only = no rps = 4/s #rpm = 100/m +mcp = no # Configure the ability to download reports generated from any of the # reporting modules @@ -177,6 +197,7 @@ auth_only = no all = yes rps = 1/s rpm = 6/m +mcp = no # Configure the ability to pull a summarized version of data stored in MongoDB # which contains potentially actionable data. @@ -189,6 +210,7 @@ auth_only = no jsonoverwrite = no rps = 1/s rpm = 4/m +mcp = no # Pull screenshots from a specific task. [taskscreenshot] @@ -196,6 +218,7 @@ enabled = yes auth_only = no rps = 1/s #rpm = 100/m +mcp = no # Pull a PCAP from a specific task [taskpcap] @@ -203,6 +226,7 @@ enabled = yes auth_only = no rps = 1/s #rpm = 10/m +mcp = no # Pull a PCAP from a specific task [tasktlspcap] @@ -210,6 +234,7 @@ enabled = yes auth_only = no rps = 1/s #rpm = 10/m +mcp = no # Pull a EVTX from a specific task [taskevtx] @@ -217,6 +242,7 @@ enabled = yes auth_only = no rps = 1/s #rpm = 10/m +mcp = no # Pull the dropped files from a specific task [taskdropped] @@ -224,6 +250,15 @@ enabled = yes auth_only = no rps = 1/s rpm = 20/m +mcp = no + +# Pull the self extracted files from a specific task +[taskselfextracted] +enabled = no +auth_only = no +rps = 1/s +rpm = 20/m +mcp = no # Pull the captured suricata files from a specific task [tasksurifile] @@ -231,11 +266,13 @@ enabled = yes auth_only = no rps = 1/s rpm = 20/m +mcp = no [taskprocdump] enabled = yes rps = 1/s rpm = 4/m +mcp = no # Download process memory dumps from a specific Task ID. [taskprocmemory] @@ -248,6 +285,7 @@ all = no compress = no rps = 1/s rpm = 4/m +mcp = no # Download a VM full memory dump from a specific Task ID. [taskfullmemory] @@ -257,10 +295,12 @@ auth_only = no compress = no rps = 1/s rpm = 2/m +mcp = no # Pull a HAR file from a specific task with mitmdump enabled [taskmitmdump] enabled = no +mcp = no # Download a sample from a specific Task ID. [sampledl] @@ -268,6 +308,7 @@ enabled = no auth_only = no rps = 2/s #rpm = 100/m +mcp = no # List all/available virtual machines. [machinelist] @@ -275,6 +316,7 @@ enabled = no auth_only = no rps = 1/s #rpm = 10/m +mcp = no # Pull information about a specific virtual machine. [machineview] @@ -282,6 +324,7 @@ enabled = no auth_only = no rps = 1/s #rpm = 10/m +mcp = no # Pull information about the Cuckoo host server. [cuckoostatus] @@ -289,6 +332,7 @@ enabled = no auth_only = no rps = 2/s #rpm = 100/m +mcp = no [rollingsuri] enabled = no @@ -296,6 +340,7 @@ auth_only = no #our max query window for rolling logs in minutes windowmax = 1440 rps = 2/s +mcp = no [rollingshrike] enabled = no @@ -303,6 +348,7 @@ auth_only = no #our max query window for rolling logs in minutes windowmax = 10080 rps = 2/s +mcp = no # Configure the ability to retrieve CAPE extracted config as JSON [capeconfig] @@ -314,6 +360,7 @@ auth_only = yes jsonoverwrite = no rps = 1/s rpm = 4/m +mcp = no # Configure the ability to download CAPE procdump files. [procdumpfiles] @@ -323,6 +370,7 @@ auth_only = no all_files = no rps = 1/s rpm = 4/m +mcp = no # Configure the ability to download CAPE payload files. [payloadfiles] @@ -332,6 +380,7 @@ auth_only = no all_files = no rps = 1/s rpm = 4/m +mcp = no # Get ids of last X hours [tasks_latest] @@ -339,6 +388,7 @@ enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no # Get summary of tasks per hours for last 24h [task_x_hours] @@ -346,31 +396,37 @@ enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no [full_memory_dump_file] enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no [full_memory_dump_file_strings] enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no [comments] enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no # Allow to request stop of the analysis inside of the VM [user_stop] enabled = no +mcp = no [mitmdump] enabled = no auth_only = no rps = 1/s rpm = 4/m +mcp = no diff --git a/docs/book/src/installation/guest/additional_configuration.rst b/docs/book/src/installation/guest/additional_configuration.rst index 63d7b4a87a6..6776695d67e 100644 --- a/docs/book/src/installation/guest/additional_configuration.rst +++ b/docs/book/src/installation/guest/additional_configuration.rst @@ -35,3 +35,18 @@ Windows automatically enables the Virus Real-time Protection One possible annoying behavior of Windows occurs when it automatically enables the real-time protection whenever an analysis is started therefore deleting the sample (if it identifies the sample as malware). To definitely turn it off you can follow one or more options listed in `this site `_. + +Enable AutoLogon +---------------- + +For features like "Reboot Analysis" (not implemented) to work properly, the VM must automatically log in the user upon boot so the agent can restart. + +To enable AutoLogon via the Registry, open an Administrator command prompt and run the following commands (replace ```` and ```` with your specific user credentials): + +.. code-block:: bat + + reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /t REG_SZ /d 1 /f + reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /t REG_SZ /d /f + reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /t REG_SZ /d /f + +Alternatively, you can use the Microsoft Sysinternals tool `Autologon `_. diff --git a/docs/book/src/usage/index.rst b/docs/book/src/usage/index.rst index cb3a537a5c7..e3a11235742 100644 --- a/docs/book/src/usage/index.rst +++ b/docs/book/src/usage/index.rst @@ -24,3 +24,4 @@ This chapter explains how to use CAPE. monitor interactive_desktop patterns_replacement + mcp diff --git a/docs/book/src/usage/mcp.rst b/docs/book/src/usage/mcp.rst new file mode 100644 index 00000000000..f6f7483ee41 --- /dev/null +++ b/docs/book/src/usage/mcp.rst @@ -0,0 +1,225 @@ +CAPE Sandbox MCP Server +======================= + +This MCP (Model Context Protocol) server allows AI agents (like Antigravity, Gemini, Claude Desktop, Cursor, etc.) to interact directly with your CAPE Sandbox instance. + +Features +-------- + +Exposes the following tools to AI agents: + +**Task Submission:** + +* ``submit_file``: Submit a local file for sandbox analysis. +* ``submit_url``: Submit a URL for sandbox analysis. +* ``submit_dlnexec``: Submit a URL for "Download & Execute" analysis. +* ``submit_static``: Submit a file for static extraction only. + +**Task Management & Search:** + +* ``search_task``: Find previous analyses by MD5/SHA1/SHA256 hashes. +* ``extended_search``: Advanced search by various criteria (filename, type, ssdeep, etc.). +* ``list_tasks``: List recent tasks with optional status filtering. +* ``view_task``: Get detailed information about a specific task. +* ``get_task_status``: Check if an analysis is pending, running, or completed. +* ``reschedule_task``: Reschedule a task to run again. +* ``reprocess_task``: Reprocess a task's existing data. +* ``delete_task``: Delete a task from the database. + +**Reports & Intelligence:** + +* ``get_task_report``: Retrieve analysis reports (json, maec, etc.). +* ``get_task_iocs``: Retrieve Indicators of Compromise (IOCs). +* ``get_task_config``: Retrieve extracted malware configuration. +* ``get_statistics``: Get global task statistics. +* ``get_latest_tasks``: Get IDs of recently finished tasks. + +**Downloads:** + +* ``download_task_screenshot``: Download analysis screenshots. +* ``download_task_pcap``: Download network traffic capture (PCAP). +* ``download_task_tlspcap``: Download TLS-decrypted network traffic. +* ``download_task_dropped``: Download files dropped during analysis. +* ``download_self_extracted_files``: Download files extracted by CAPE (e.g. unpacked payloads). +* ``download_task_payloadfiles``: Download CAPE payload files. +* ``download_task_procdumpfiles``: Download process dumps. +* ``download_task_procmemory``: Download process memory dumps. +* ``download_task_fullmemory``: Download full VM memory dump. +* ``download_task_evtx``: Download Windows Event Logs (EVTX). +* ``download_task_surifile``: Download Suricata captured files. +* ``download_task_mitmdump``: Download mitmdump HAR file. + +**Infrastructure & Files:** + +* ``list_machines``: See available analysis VMs. +* ``view_machine``: Get details about a specific VM. +* ``list_exitnodes``: List available network exit nodes. +* ``view_file``: Get information about a file in the database. +* ``download_sample``: Download a malware sample from the database. +* ``get_cuckoo_status``: Get the health/status of the CAPE host. + +Installation +------------ + +You can install the required dependencies using the ``mcp`` extra: + +.. code-block:: bash + + poetry run pip install .[mcp] + +Configuration +------------- + +The MCP server integrates with CAPE's standard configuration system and environment variables. + +Environment Variables +~~~~~~~~~~~~~~~~~~~~~ + +* ``CAPE_API_URL``: (Optional) The full path to your CAPE API v2 endpoint (e.g., ``http://127.0.0.1:8000/apiv2``). If not set, it defaults to the ``url`` in ``api.conf`` + ``/apiv2``. +* ``CAPE_API_TOKEN``: (Optional) Your API token. Recommended to set this in the **Client Configuration** (e.g. ``claude_desktop_config.json``) rather than your system's global environment variables to ensure isolation. +* ``CAPE_ALLOWED_SUBMISSION_DIR``: (Optional) Restricts ``submit_file`` to a specific local directory for security. Defaults to the current working directory. + +Granular Tool Control (``api.conf``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can enable or disable specific tools for the MCP server by modifying ``conf/api.conf``. Each API section now supports an ``mcp`` toggle: + +.. code-block:: ini + + [filecreate] + enabled = yes + mcp = yes # Set to 'no' to hide this tool from the AI agent + + [taskdelete] + enabled = no + mcp = no # AI will not see 'delete_task' + +**Note:** Tools disabled via ``mcp = no`` are not even registered with the MCP server; the AI agent will not see them in its available toolset. + +Authentication & Security Architecture +-------------------------------------- + +Proper security depends on how you deploy the MCP server. + +Scenario A: Local / Stdio (Recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this mode, the AI Client (Claude, Gemini, etc.) spawns a private instance of the MCP server process for your session. This is the most secure method. + +1. **Configuration:** Add your ``CAPE_API_TOKEN`` to the **Client's Configuration file** (e.g., ``claude_desktop_config.json``), **not** your system's global environment variables. +2. **Isolation:** Because the process is spawned by your client, the token is isolated to your session. Other users on the machine cannot see or use your token. +3. **Usage:** You do **not** need to provide a token in your prompts. The server automatically uses the environment variable provided by the client. + +Scenario B: Remote / Shared Server (SSE) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this mode, a single MCP server instance runs continuously and accepts connections from multiple clients over the network. + +1. **Configuration:** Start the server **without** a ``CAPE_API_TOKEN`` environment variable. +2. **Strict Mode:** Ensure ``token_auth_enabled = yes`` is set in ``conf/api.conf``. +3. **Usage:** Users **must** provide their API token in the ``token`` argument for every tool call (e.g., ``submit_file(..., token="MyKey")``). +4. **Risk:** Do not set a global token in this mode, or all users will inherit those privileges. + +Authentication Priority +~~~~~~~~~~~~~~~~~~~~~~~ + +The server determines which token to use in this specific order: + +1. **Per-Request Token:** Argument passed to the specific tool (e.g., ``token="xyz"``). +2. **Environment Token:** ``CAPE_API_TOKEN`` variable (set in Client Config for Stdio). + +If ``token_auth_enabled = yes`` and no token is found in either location, the request is rejected. + +Running the Server +------------------ + +Standard execution +~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + CAPE_API_URL=http://your-cape-ip:8000/apiv2 CAPE_API_TOKEN=your_token python3 web/mcp_server.py + +Client Integrations +------------------- + +The CAPE MCP server can be used with any MCP-compliant client. Here are examples for popular clients. + +Claude Desktop +~~~~~~~~~~~~~~ + +Add the following to your ``claude_desktop_config.json``: + +.. code-block:: json + + { + "mcpServers": { + "cape": { + "command": "poetry", + "args": ["run", "python", "/opt/CAPEv2/web/mcp_server.py"], + "env": { + "CAPE_API_URL": "http://127.0.0.1:8000/apiv2", + "CAPE_API_TOKEN": "YOUR_API_TOKEN_HERE", + "CAPE_ALLOWED_SUBMISSION_DIR": "/home/user/samples" + } + } + } + } + +Gemini CLI +~~~~~~~~~~ + +You can add the server using the CLI command: + +.. code-block:: bash + + gemini mcp add cape poetry run python /opt/CAPEv2/web/mcp_server.py \ + -e CAPE_API_URL=http://127.0.0.1:8000/apiv2 \ + -e CAPE_API_TOKEN=YOUR_API_TOKEN_HERE \ + -e CAPE_ALLOWED_SUBMISSION_DIR=/home/user/samples + +Or manually add it to your ``~/.gemini/settings.json``: + +.. code-block:: json + + { + "mcpServers": { + "cape": { + "command": "poetry", + "args": ["run", "python", "/opt/CAPEv2/web/mcp_server.py"], + "env": { + "CAPE_API_URL": "http://127.0.0.1:8000/apiv2", + "CAPE_API_TOKEN": "YOUR_API_TOKEN_HERE", + "CAPE_ALLOWED_SUBMISSION_DIR": "/home/user/samples" + } + } + } + } + +Antigravity +~~~~~~~~~~~ + +Open **Agent Panel** -> **...** -> **MCP Servers** -> **Manage MCP Servers** -> **View raw config** and add the following to ``mcp_config.json``: + +.. code-block:: json + + { + "mcpServers": { + "cape": { + "command": "poetry", + "args": ["run", "python", "/opt/CAPEv2/web/mcp_server.py"], + "env": { + "CAPE_API_URL": "http://127.0.0.1:8000/apiv2", + "CAPE_API_TOKEN": "YOUR_API_TOKEN_HERE", + "CAPE_ALLOWED_SUBMISSION_DIR": "/home/user/samples" + } + } + } + } + +Security Note +------------- + +* **Tool Filtering:** Use the ``mcp = no`` setting in ``api.conf`` to hide dangerous operations (like ``task_delete``) from the AI. +* **Path Restriction:** Use ``CAPE_ALLOWED_SUBMISSION_DIR`` to ensure the AI cannot submit arbitrary sensitive files from your host system. +* **Auth Enforcement:** Enable ``token_auth_enabled`` in ``api.conf`` to ensure all interactions are authenticated. diff --git a/poetry.lock b/poetry.lock index 64b8e862adb..42c432ebbbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,6 +190,19 @@ SQLAlchemy = ">=1.3.0" [package.extras] tz = ["python-dateutil"] +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1415,6 +1428,31 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] +[[package]] +name = "fastmcp" +version = "1.0" +description = "A more ergonomic interface for MCP servers" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "fastmcp-1.0-py3-none-any.whl", hash = "sha256:88f0c5acc2af06f22cf46dd26c1a1c4c54f1479ef09e5f871fdfbade6defe3a6"}, + {file = "fastmcp-1.0.tar.gz", hash = "sha256:202f454e82cb68460a2b7372f975901e78e03b27734ce3a16c4d1d3e3cdbc519"}, +] + +[package.dependencies] +httpx = ">=0.26.0" +mcp = ">=1.0.0,<2.0.0" +pydantic = ">=2.5.3,<3.0.0" +pydantic-settings = ">=2.6.1" +python-dotenv = ">=1.0.1" +typer = ">=0.9.0" + +[package.extras] +dev = ["copychat (>=0.5.2)", "ipython (>=8.12.3)", "pdbpp (>=0.10.3)", "pre-commit", "pyright (>=1.1.389)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.23.5)", "pytest-flakefinder", "pytest-xdist (>=3.6.1)", "ruff"] +tests = ["pre-commit", "pyright (>=1.1.389)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.23.5)", "pytest-flakefinder", "pytest-xdist (>=3.6.1)", "ruff"] + [[package]] name = "filelock" version = "3.20.3" @@ -2333,6 +2371,19 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "httpx-sse" +version = "0.4.3" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, + {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, +] + [[package]] name = "humanize" version = "4.11.0" @@ -3010,6 +3061,27 @@ files = [ {file = "maxminddb-2.6.3.tar.gz", hash = "sha256:d2c3806baa7aa047aa1bac7419e7e353db435f88f09d51106a84dbacf645d254"}, ] +[[package]] +name = "mcp" +version = "1.1.3" +description = "Model Context Protocol SDK" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "mcp-1.1.3-py3-none-any.whl", hash = "sha256:71462d6cd7c06c14689dfcf110ff22286ba1b608cfc3515c0a5cbe33d131731a"}, + {file = "mcp-1.1.3.tar.gz", hash = "sha256:af11018b8e9153cdd25f3722ec639fe7a462c00213a330fd6f593968341a9883"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27" +httpx-sse = ">=0.4" +pydantic = ">=2.7.2" +sse-starlette = ">=1.6.1" +starlette = ">=0.27" + [[package]] name = "mdurl" version = "0.1.2" @@ -4467,6 +4539,31 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.13.0" +description = "Settings management using Pydantic" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246"}, + {file = "pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pydeep2" version = "0.5.1" @@ -5656,6 +5753,19 @@ linux = ["python-magic (>=0.4.13) ; sys_platform == \"linux\""] shellcode = ["unicorn (>=2.0.0)", "yara-python (>=4.1.0)"] windows = ["python-magic-bin (>=0.4.14) ; sys_platform == \"win32\""] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.17.0" @@ -5845,6 +5955,49 @@ files = [ dev = ["build", "hatch"] doc = ["sphinx"] +[[package]] +name = "sse-starlette" +version = "3.2.0" +description = "SSE plugin for Starlette" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf"}, + {file = "sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422"}, +] + +[package.dependencies] +anyio = ">=4.7.0" +starlette = ">=0.49.1" + +[package.extras] +daphne = ["daphne (>=4.2.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "uvicorn (>=0.34.0)"] +granian = ["granian (>=2.3.1)"] +uvicorn = ["uvicorn (>=0.34.0)"] + +[[package]] +name = "starlette" +version = "0.52.1" +description = "The little ASGI library that shines." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, + {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, +] + +[package.dependencies] +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"] + [[package]] name = "storage3" version = "0.12.1" @@ -6084,6 +6237,25 @@ all = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] dev = ["pep8 (>=1.6.2)", "pyenchant (>=1.6.6)", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "sphinx (>=1.2.3)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-spelling (>=2.1.2)", "tox (>=2.1.1)", "tox-gh-actions (>=2.2.0)", "twine (>=1.6.5)", "wheel"] twisted = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] +[[package]] +name = "typer" +version = "0.23.1" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e"}, + {file = "typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" + [[package]] name = "types-requests" version = "2.32.4.20260107" @@ -6877,8 +7049,9 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [extras] gcp = ["google-cloud-pubsub", "google-cloud-storage"] maco = ["maco"] +mcp = ["fastmcp", "httpx"] [metadata] lock-version = "2.1" python-versions = ">=3.10, <4.0" -content-hash = "f034d453baf2de501d8b1475d394670f7f459b98107eea75d01191d90a12bb80" +content-hash = "cb03a48d812fd739b002f938e9c8d755fa0a1e86c7d2432b6493e1157eea4a4f" diff --git a/pyproject.toml b/pyproject.toml index 43718f41946..0ebe284f7e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ dependencies = [ [project.optional-dependencies] maco = ["maco"] gcp = ["google-cloud-storage", "google-cloud-pubsub"] +mcp = ["fastmcp", "httpx"] [dependency-groups] dev = [ diff --git a/web/apiv2/urls.py b/web/apiv2/urls.py index a4ef7337222..9c80ef461f6 100644 --- a/web/apiv2/urls.py +++ b/web/apiv2/urls.py @@ -51,6 +51,8 @@ re_path(r"^tasks/get/tlspcap/(?P\d+)/$", views.tasks_tlspcap), re_path(r"^tasks/get/evtx/(?P\d+)/$", views.tasks_evtx), re_path(r"^tasks/get/dropped/(?P\d+)/$", views.tasks_dropped), + re_path(r"^tasks/get/selfextracted/(?P\d+)/$", views.tasks_selfextracted), + re_path(r"^tasks/get/selfextracted/(?P\d+)/(?P[\w\-\.]+)/$", views.tasks_selfextracted), re_path(r"^tasks/get/surifile/(?P\d+)/$", views.tasks_surifile), re_path(r"^tasks/get/mitmdump/(?P\d+)/$", views.tasks_mitmdump), re_path(r"^tasks/get/payloadfiles/(?P\d+)/$", views.tasks_payloadfiles), diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 0657abd08a8..cf8fba6f818 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -1790,6 +1790,104 @@ def tasks_dropped(request, task_id): return resp +@csrf_exempt +@api_view(["GET"]) +def tasks_selfextracted(request, task_id, tool="all"): + if not apiconf.taskselfextracted.get("enabled"): + resp = {"error": True, "error_value": "Self Extracted File download API is disabled"} + return Response(resp) + + check = validate_task(task_id) + if check["error"]: + return Response(check) + + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + + rtid = check.get("rtid", 0) + if rtid: + task_id = rtid + + srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "selfextracted") + if not os.path.normpath(srcdir).startswith(ANALYSIS_BASE_PATH): + return render(request, "error.html", {"error": f"File not found: {os.path.basename(srcdir)}"}) + + if not path_exists(srcdir) or not len(os.listdir(srcdir)): + resp = {"error": True, "error_value": "No self extracted files for task %s" % task_id} + return Response(resp) + + selfextract_data = {} + + if repconf.mongodb.enabled: + tmp = mongo_find_one("analysis", {"info.id": int(task_id)}, {"selfextract": 1}) + if tmp and "selfextract" in tmp: + selfextract_data = tmp["selfextract"] + elif es_as_db: + tmp = es.search( + index=get_analysis_index(), query=get_query_by_info_id(str(task_id)), _source=["selfextract"] + )["hits"]["hits"] + if tmp: + selfextract_data = tmp[-1]["_source"].get("selfextract", {}) + + if not selfextract_data: + jfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "reports", "report.json") + if path_exists(jfile): + try: + with open(jfile, "r") as f: + rep = json.load(f) + selfextract_data = rep.get("selfextract", {}) + except Exception as e: + log.error(e) + + if tool != "all" and tool not in selfextract_data: + resp = {"error": True, "error_value": f"Tool {tool} not found in analysis data"} + return Response(resp) + + mem_zip = BytesIO() + with zipfile.ZipFile(mem_zip, "w", zipfile.ZIP_DEFLATED) as zf: + if tool == "all": + if selfextract_data: + processed_sha256s = set() + for tname, tdata in selfextract_data.items(): + for fmeta in tdata.get("extracted_files", []): + sha256 = fmeta.get("sha256") + if not sha256 or not re.match(r"^[a-fA-F0-9]{64}$", sha256): + continue + + fpath = os.path.join(srcdir, sha256) + if not os.path.exists(fpath): + continue + + arcname = os.path.join(tname, sha256) + zf.write(fpath, arcname) + processed_sha256s.add(sha256) + + for f in os.listdir(srcdir): + if f not in processed_sha256s: + zf.write(os.path.join(srcdir, f), f) + else: + for f in os.listdir(srcdir): + zf.write(os.path.join(srcdir, f), f) + else: + tdata = selfextract_data[tool] + for fmeta in tdata.get("extracted_files", []): + sha256 = fmeta.get("sha256") + if not sha256 or not re.match(r"^[a-fA-F0-9]{64}$", sha256): + continue + + fpath = os.path.join(srcdir, sha256) + if not os.path.exists(fpath): + continue + + zf.write(fpath, sha256) + + mem_zip.seek(0) + resp = StreamingHttpResponse(mem_zip, content_type="application/zip") + resp["Content-Length"] = len(mem_zip.getvalue()) + resp["Content-Disposition"] = f"attachment; filename={task_id}_selfextracted_{tool}.zip" + return resp + + @csrf_exempt @api_view(["GET"]) def tasks_surifile(request, task_id): diff --git a/web/mcp_server.py b/web/mcp_server.py new file mode 100644 index 00000000000..93bd01cd172 --- /dev/null +++ b/web/mcp_server.py @@ -0,0 +1,565 @@ +import json +import os +import sys +import mimetypes +import re +from typing import Any, Dict + +# Ensure CAPE root is in path for lib imports +CAPE_ROOT = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..") +sys.path.append(CAPE_ROOT) + +try: + import httpx + from fastmcp import FastMCP +except ImportError: + sys.exit("poetry run pip install .[mcp]") + +try: + from lib.cuckoo.common.config import Config + from lib.cuckoo.common.web_utils import ( + search_term_map, + perform_search_filters, + hash_searches, + normalized_lower_terms, + ) +except ImportError: + sys.exit("Could not import lib.cuckoo.common.config. Ensure you are running from CAPE root.") + +# Initialize CAPE Config +api_config = Config("api") + +# Configuration from Environment or Config File +# Run with: CAPE_API_URL=http://127.0.0.1:8000/apiv2 CAPE_API_TOKEN=your_token python3 web/mcp_server.py +API_URL = os.environ.get("CAPE_API_URL") +if not API_URL: + # Try to get from api.conf [api] url + try: + base_url = api_config.api.url.rstrip("/") + API_URL = f"{base_url}/apiv2" + except AttributeError: + API_URL = "http://127.0.0.1:8000/apiv2" + +API_TOKEN = os.environ.get("CAPE_API_TOKEN", "") + +# Proactively map enabled MCP tools. Default is NO. +ENABLED_MCP_TOOLS = set() +for section_name in api_config.get_config(): + if section_name == "api": + continue + try: + section = api_config.get(section_name) + if getattr(section, "mcp", False): + ENABLED_MCP_TOOLS.add(section_name) + except Exception: + continue + +def check_mcp_enabled(section: str) -> bool: + """Check if a specific section is enabled for MCP.""" + return section in ENABLED_MCP_TOOLS + +def mcp_tool(section: str): + """ + Conditional decorator that only registers the tool with FastMCP + if the corresponding section is enabled in api.conf. + """ + def decorator(func): + if check_mcp_enabled(section): + return mcp.tool()(func) + return func + return decorator + +def is_auth_required() -> bool: + """Check if token authorization is enabled globally.""" + try: + return api_config.api.token_auth_enabled + except AttributeError: + return False + +# Startup Check: Warn if Auth is enabled but no default token is provided +if is_auth_required() and not API_TOKEN: + print("WARNING: Token authentication is enabled in api.conf, but CAPE_API_TOKEN is not set.", file=sys.stderr) + print(" All MCP tool calls must include a valid 'token' argument.", file=sys.stderr) + +# Initialize FastMCP +mcp = FastMCP("cape-sandbox") + +# Security: Restrict file submission to a specific directory +# Defaults to current working directory if not set +ALLOWED_SUBMISSION_DIR = os.environ.get("CAPE_ALLOWED_SUBMISSION_DIR", os.getcwd()) + +def get_headers(token: str = "") -> Dict[str, str]: + headers = {} + auth_token = token if token else API_TOKEN + + if auth_token: + headers["Authorization"] = f"Token {auth_token}" + return headers + +async def _request(method: str, endpoint: str, token: str = "", **kwargs) -> Any: + # Auth Check + if is_auth_required(): + auth_token = token if token else API_TOKEN + if not auth_token: + return {"error": True, "message": "Authentication required but no token provided."} + + url = f"{API_URL.rstrip('/')}/{endpoint.lstrip('/')}" + async with httpx.AsyncClient() as client: + try: + response = await client.request(method, url, headers=get_headers(token), **kwargs) + # We don't raise_for_status immediately to handle API errors gracefully in JSON + if response.status_code >= 400: + try: + return response.json() + except json.JSONDecodeError: + return {"error": True, "message": f"HTTP {response.status_code}", "body": response.text} + + try: + return response.json() + except json.JSONDecodeError: + return {"error": False, "data": response.text} + except httpx.HTTPStatusError as e: + return {"error": True, "message": str(e), "body": e.response.text} + except Exception as e: + return {"error": True, "message": str(e)} + +async def _download_file(endpoint: str, destination: str, default_filename: str = "downloaded_file.bin", token: str = "") -> str: + """Helper to download a file from an API endpoint.""" + # Auth Check + if is_auth_required(): + auth_token = token if token else API_TOKEN + if not auth_token: + return json.dumps({"error": True, "message": "Authentication required but no token provided."}, indent=2) + + if not os.path.isdir(destination): + return json.dumps({"error": True, "message": "Destination directory does not exist"}) + + url = f"{API_URL.rstrip('/')}/{endpoint.lstrip('/')}" + headers = get_headers(token) + + async with httpx.AsyncClient() as client: + try: + async with client.stream("GET", url, headers=headers) as response: + if response.status_code != 200: + content = await response.read() + return json.dumps({"error": True, "message": f"HTTP {response.status_code}", "body": content.decode('utf-8', errors='ignore')}, indent=2) + + filename = default_filename + content_disposition = response.headers.get("content-disposition") + if content_disposition: + match = re.search(r'filename="?([^"]+)"?', content_disposition) + if match: + filename = os.path.basename(match.group(1)) + + filepath = os.path.join(destination, filename) + + with open(filepath, "wb") as f: + async for chunk in response.aiter_bytes(): + f.write(chunk) + + return json.dumps({"error": False, "message": f"Saved to {filepath}", "path": filepath}, indent=2) + except Exception as e: + return json.dumps({"error": True, "message": str(e)}, indent=2) + +def _build_submission_data(**kwargs) -> Dict[str, str]: + """Helper to build submission data dictionary, handling type conversions.""" + data = {} + for key, value in kwargs.items(): + # Skip empty values (None, "", 0, False) to match original behavior + if not value: + continue + + if isinstance(value, bool): + data[key] = "1" + elif isinstance(value, int): + data[key] = str(value) + else: + data[key] = value + return data + +# --- Tasks Creation --- + +@mcp_tool("filecreate") +async def submit_file( + file_path: str, + machine: str = "", + package: str = "", + options: str = "", + tags: str = "", + priority: int = 1, + timeout: int = 0, + platform: str = "", + memory: bool = False, + enforce_timeout: bool = False, + clock: str = "", + custom: str = "", + token: str = "" +) -> str: + """ + Submit a local file for analysis. + """ + # Auth Check (Manual check needed here because we stream file) + if is_auth_required(): + auth_token = token if token else API_TOKEN + if not auth_token: + return json.dumps({"error": True, "message": "Authentication required but no token provided."}) + + if not os.path.exists(file_path): + return json.dumps({"error": True, "message": "File not found"}) + + # Security check: Ensure file is within allowed directory + abs_file_path = os.path.abspath(file_path) + abs_allowed_dir = os.path.abspath(ALLOWED_SUBMISSION_DIR) + + if not abs_file_path.startswith(abs_allowed_dir): + return json.dumps({ + "error": True, + "message": f"Security Violation: File submission is restricted to {abs_allowed_dir}" + }) + + filename = os.path.basename(file_path) + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + data = _build_submission_data( + machine=machine, package=package, options=options, tags=tags, + priority=priority, timeout=timeout, platform=platform, + memory=memory, enforce_timeout=enforce_timeout, clock=clock, + custom=custom + ) + + url = f"{API_URL.rstrip('/')}/tasks/create/file/" + + async with httpx.AsyncClient() as client: + try: + with open(file_path, "rb") as f: + files = {"file": (filename, f, mime_type)} + response = await client.post(url, data=data, files=files, headers=get_headers(token)) + try: + result = response.json() + except json.JSONDecodeError: + result = {"error": response.status_code >= 400, "data": response.text} + except Exception as e: + result = {"error": True, "message": str(e)} + + return json.dumps(result, indent=2) + +@mcp_tool("urlcreate") +async def submit_url( + url: str, + machine: str = "", + package: str = "", + options: str = "", + tags: str = "", + priority: int = 1, + timeout: int = 0, + platform: str = "", + memory: bool = False, + enforce_timeout: bool = False, + clock: str = "", + custom: str = "", + token: str = "" +) -> str: + """Submit a URL for analysis.""" + data = {"url": url} + data.update(_build_submission_data( + machine=machine, package=package, options=options, tags=tags, + priority=priority, timeout=timeout, platform=platform, + memory=memory, enforce_timeout=enforce_timeout, clock=clock, + custom=custom + )) + + result = await _request("POST", "tasks/create/url/", token=token, data=data) + return json.dumps(result, indent=2) + +@mcp_tool("dlnexeccreate") +async def submit_dlnexec( + url: str, + machine: str = "", + package: str = "", + options: str = "", + tags: str = "", + priority: int = 1, + token: str = "" +) -> str: + """Submit a URL for Download & Execute analysis.""" + data = {"dlnexec": url} + data.update(_build_submission_data( + machine=machine, package=package, options=options, tags=tags, priority=priority + )) + + result = await _request("POST", "tasks/create/dlnexec/", token=token, data=data) + return json.dumps(result, indent=2) + +@mcp_tool("staticextraction") +async def submit_static( + file_path: str, + priority: int = 1, + options: str = "", + token: str = "" +) -> str: + """Submit a file for static extraction only.""" + # Auth Check (Manual check needed here because we stream file) + if is_auth_required(): + auth_token = token if token else API_TOKEN + if not auth_token: + return json.dumps({"error": True, "message": "Authentication required but no token provided."}) + + if not os.path.exists(file_path): + return json.dumps({"error": True, "message": "File not found"}) + + # Security check: Ensure file is within allowed directory + abs_file_path = os.path.abspath(file_path) + abs_allowed_dir = os.path.abspath(ALLOWED_SUBMISSION_DIR) + + if not abs_file_path.startswith(abs_allowed_dir): + return json.dumps({ + "error": True, + "message": f"Security Violation: File submission is restricted to {abs_allowed_dir}" + }) + + filename = os.path.basename(file_path) + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + data = _build_submission_data(priority=priority, options=options) + + url = f"{API_URL.rstrip('/')}/tasks/create/static/" + + async with httpx.AsyncClient() as client: + try: + with open(file_path, "rb") as f: + files = {"file": (filename, f, mime_type)} + response = await client.post(url, data=data, files=files, headers=get_headers(token)) + try: + result = response.json() + except json.JSONDecodeError: + result = {"error": response.status_code >= 400, "data": response.text} + except Exception as e: + result = {"error": True, "message": str(e)} + + return json.dumps(result, indent=2) + +# --- Task Management & Search --- + +@mcp_tool("tasksearch") +async def search_task(hash_value: str, token: str = "") -> str: + """Search for tasks by MD5, SHA1, or SHA256.""" + algo = "md5" + if len(hash_value) == 40: + algo = "sha1" + elif len(hash_value) == 64: + algo = "sha256" + + result = await _request("GET", f"tasks/search/{algo}/{hash_value}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("extendedtasksearch") +async def extended_search(option: str, argument: str, token: str = "") -> str: + """ + Search tasks using extended options. + Options include: id, name, type, string, ssdeep, crc32, file, command, resolvedapi, key, mutex, domain, ip, signature, signame, etc. + """ + data = {"option": option, "argument": argument} + result = await _request("POST", "tasks/extendedsearch/", token=token, data=data) + return json.dumps(result, indent=2) + +@mcp_tool("extendedtasksearch") +async def get_search_info() -> str: + """ + Retrieve the available advanced search terms, filters, and hash types. + Use this information to construct valid queries for `extended_search`. + """ + return json.dumps({ + "search_term_map": search_term_map, + "perform_search_filters": perform_search_filters, + "hash_searches": hash_searches, + "normalized_lower_terms": normalized_lower_terms + }, indent=2, default=str) + +@mcp_tool("tasklist") +async def list_tasks(limit: int = 10, offset: int = 0, status: str = "", token: str = "") -> str: + """List tasks with optional limit, offset and status filter.""" + params = {} + if status: + params["status"] = status + + endpoint = f"tasks/list/{limit}/{offset}/" + result = await _request("GET", endpoint, token=token, params=params) + return json.dumps(result, indent=2) + +@mcp_tool("taskview") +async def view_task(task_id: int, token: str = "") -> str: + """Get details of a specific task.""" + result = await _request("GET", f"tasks/view/{task_id}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("taskresched") +async def reschedule_task(task_id: int, token: str = "") -> str: + """Reschedule a task.""" + result = await _request("GET", f"tasks/reschedule/{task_id}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("taskreprocess") +async def reprocess_task(task_id: int, token: str = "") -> str: + """Reprocess a task.""" + result = await _request("GET", f"tasks/reprocess/{task_id}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("taskstatus") +async def get_task_status(task_id: int, token: str = "") -> str: + """Get the status of a task.""" + result = await _request("GET", f"tasks/status/{task_id}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("tasks_latest") +async def get_latest_tasks(hours: int = 24, token: str = "") -> str: + """Get IDs of tasks finished in the last X hours.""" + result = await _request("GET", f"tasks/get/latests/{hours}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("statistics") +async def get_statistics(days: int = 7, token: str = "") -> str: + """Get task statistics for the last X days.""" + result = await _request("GET", f"tasks/statistics/{days}/", token=token) + return json.dumps(result, indent=2) + +# --- Reports & IOCs --- + +@mcp_tool("taskreport") +async def get_task_report(task_id: int, format: str = "json", token: str = "") -> str: + """Get the analysis report for a task (json, lite, maec, metadata).""" + result = await _request("GET", f"tasks/get/report/{task_id}/{format}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("taskiocs") +async def get_task_iocs(task_id: int, detailed: bool = False, token: str = "") -> str: + """Get IOCs for a task.""" + endpoint = f"tasks/get/iocs/{task_id}/" + if detailed: + endpoint += "detailed/" + result = await _request("GET", endpoint, token=token) + return json.dumps(result, indent=2) + +@mcp_tool("capeconfig") +async def get_task_config(task_id: int, token: str = "") -> str: + """Get the extracted malware configuration for a task.""" + result = await _request("GET", f"tasks/get/config/{task_id}/", token=token) + return json.dumps(result, indent=2) + +# --- File Downloads --- + +@mcp_tool("taskscreenshot") +async def download_task_screenshot(task_id: int, destination: str, screenshot_id: str = "all", token: str = "") -> str: + """Download task screenshots (zip or single image).""" + return await _download_file(f"tasks/get/screenshot/{task_id}/{screenshot_id}/", destination, f"{task_id}_screenshots.zip", token=token) + +@mcp_tool("taskpcap") +async def download_task_pcap(task_id: int, destination: str, token: str = "") -> str: + """Download the PCAP file for a task.""" + return await _download_file(f"tasks/get/pcap/{task_id}/", destination, f"{task_id}_dump.pcap", token=token) + +@mcp_tool("tasktlspcap") +async def download_task_tlspcap(task_id: int, destination: str, token: str = "") -> str: + """Download the TLS PCAP file for a task.""" + return await _download_file(f"tasks/get/tlspcap/{task_id}/", destination, f"{task_id}_tls.pcap", token=token) + +@mcp_tool("taskevtx") +async def download_task_evtx(task_id: int, destination: str, token: str = "") -> str: + """Download the EVTX logs for a task.""" + return await _download_file(f"tasks/get/evtx/{task_id}/", destination, f"{task_id}_evtx.zip", token=token) + +@mcp_tool("taskdropped") +async def download_task_dropped(task_id: int, destination: str, token: str = "") -> str: + """Download dropped files for a task.""" + return await _download_file(f"tasks/get/dropped/{task_id}/", destination, f"{task_id}_dropped.zip", token=token) + +@mcp_tool("taskselfextracted") +async def download_self_extracted_files(task_id: int, destination: str, tool: str = "all", token: str = "") -> str: + """Download self-extracted files for a task.""" + return await _download_file(f"tasks/get/selfextracted/{task_id}/{tool}/", destination, f"{task_id}_selfextracted_{tool}.zip", token=token) + +@mcp_tool("tasksurifile") +async def download_task_surifile(task_id: int, destination: str, token: str = "") -> str: + """Download Suricata files for a task.""" + return await _download_file(f"tasks/get/surifile/{task_id}/", destination, f"{task_id}_surifiles.zip", token=token) + +@mcp_tool("taskmitmdump") +async def download_task_mitmdump(task_id: int, destination: str, token: str = "") -> str: + """Download mitmdump HAR file for a task.""" + return await _download_file(f"tasks/get/mitmdump/{task_id}/", destination, f"{task_id}_dump.har", token=token) + +@mcp_tool("payloadfiles") +async def download_task_payloadfiles(task_id: int, destination: str, token: str = "") -> str: + """Download CAPE payload files.""" + return await _download_file(f"tasks/get/payloadfiles/{task_id}/", destination, f"{task_id}_payloads.zip", token=token) + +@mcp_tool("procdumpfiles") +async def download_task_procdumpfiles(task_id: int, destination: str, token: str = "") -> str: + """Download CAPE procdump files.""" + return await _download_file(f"tasks/get/procdumpfiles/{task_id}/", destination, f"{task_id}_procdumps.zip", token=token) + +@mcp_tool("taskprocmemory") +async def download_task_procmemory(task_id: int, destination: str, pid: str = "all", token: str = "") -> str: + """Download process memory dumps.""" + return await _download_file(f"tasks/get/procmemory/{task_id}/{pid}/", destination, f"{task_id}_procmemory.zip", token=token) + +@mcp_tool("taskfullmemory") +async def download_task_fullmemory(task_id: int, destination: str, token: str = "") -> str: + """Download full VM memory dump.""" + return await _download_file(f"tasks/get/fullmemory/{task_id}/", destination, f"{task_id}_fullmemory.dmp", token=token) + +# --- Files & Machines --- + +@mcp_tool("fileview") +async def view_file(hash_value: str, hash_type: str = "sha256", token: str = "") -> str: + """View information about a file in the database.""" + return await _request("GET", f"files/view/{hash_type}/{hash_value}/", token=token) + +@mcp_tool("sampledl") +async def download_sample(hash_value: str, destination: str, hash_type: str = "sha256", token: str = "") -> str: + """Download a sample from the database.""" + return await _download_file(f"files/get/{hash_type}/{hash_value}/", destination, f"{hash_value}.bin", token=token) + +@mcp_tool("machinelist") +async def list_machines(token: str = "") -> str: + """List available analysis machines.""" + result = await _request("GET", "machines/list/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("machineview") +async def view_machine(name: str, token: str = "") -> str: + """View details of a specific machine.""" + result = await _request("GET", f"machines/view/{name}/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("list_exitnodes") +async def list_exitnodes(token: str = "") -> str: + """List available exit nodes.""" + result = await _request("GET", "exitnodes/", token=token) + return json.dumps(result, indent=2) + +@mcp_tool("cuckoostatus") +async def get_cuckoo_status(token: str = "") -> str: + """Get the status of the CAPE host.""" + result = await _request("GET", "cuckoo/status/", token=token) + return json.dumps(result, indent=2) + +@mcp.tool() +async def verify_auth(token: str = "") -> str: + """ + Verify if the provided API token is valid. + Useful for checking authentication status before performing other operations. + """ + # We use a lightweight endpoint like cuckoo status to check auth + result = await _request("GET", "cuckoo/status/", token=token) + + if isinstance(result, dict) and result.get("error"): + return json.dumps({"authenticated": False, "message": "Invalid token or authentication failed.", "details": result}, indent=2) + + return json.dumps({"authenticated": True, "message": "Token is valid.", "user": "Authenticated User"}, indent=2) + +if __name__ == "__main__": + mcp.run() diff --git a/web/templates/apiv2/index.html b/web/templates/apiv2/index.html index 9af6e46e775..59a53e232a1 100644 --- a/web/templates/apiv2/index.html +++ b/web/templates/apiv2/index.html @@ -549,6 +549,32 @@

API + + + Task Self Extracted Files + + {% if config.taskselfextracted.enabled %} + Yes + {% else %} + No + {% endif %} + + +
    +
  • RPS: {{ config.taskselfextracted.rps }}
  • +
  • RPM: {{ config.taskselfextracted.rpm }}
  • +
+ + Download the self extracted files associated with a Task by ID. Return object will be application/zip (.zip). + + + + +
curl {{ config.api.url }}/apiv2/tasks/get/selfextracted/[task id]/
+curl {{ config.api.url }}/apiv2/tasks/get/selfextracted/[task id]/[tool]/
+ + + Task SuriFiles