"""Security checks for user-configured MCP server entries. MCP stdio transports intentionally support arbitrary local commands so users can run custom servers. This module does not try to sandbox that capability. It blocks two high-signal abuse shapes seen in the wild: 1. The exfiltration shape from #45620: a shell interpreter whose inline script invokes network egress tooling. 2. The persistence shape from the June 2026 ``hermes-0day`` campaign: a shell interpreter whose inline script writes to OS persistence surfaces (``~/.ssh/authorized_keys``, ``/etc/ssh``, ``/etc/pam.d``, ``sudoers``, crontab, shell rc files). The campaign planted ``command: bash`` MCP entries whose payload appended an attacker SSH key to ``authorized_keys``; Hermes re-executed them on every cron tick / startup, re-installing the backdoor. 3. A hardcoded indicator-of-compromise (IOC) blocklist for that campaign — the attacker's ``hermes-0day`` SSH public key and source IPs. Any entry whose command/args/env carry an IOC is refused outright, regardless of shape, so a pre-planted ``config.yaml`` cannot spawn it. These checks run BOTH at save time (``_save_mcp_server`` — dashboard API + CLI) and at spawn time (``tools.mcp_tool._filter_suspicious_mcp_servers`` — discovery / cron / startup), so a hand-edited or pre-planted entry is also caught before it can execute. """ from __future__ import annotations import os import re import shlex from typing import Any _SHELL_INTERPRETERS = frozenset({ "bash", "sh", "zsh", "dash", "fish", "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe", }) _EGRESS_PATTERN = re.compile( r"(? str: text = str(command or "").strip() if not text: return "" try: parts = shlex.split(text, posix=(os.name != "nt")) except ValueError: parts = text.split() first = parts[0] if parts else text return os.path.basename(first).lower() def _inline_script(args: Any) -> str: if args is None: return "" if isinstance(args, (list, tuple)): return " ".join(str(item) for item in args) return str(args) def _entry_text(entry: dict[str, Any]) -> str: """Flatten command + args + env values into one string for IOC scanning.""" parts: list[str] = [str(entry.get("command") or "")] parts.append(_inline_script(entry.get("args"))) env = entry.get("env") if isinstance(env, dict): parts.extend(str(v) for v in env.values()) return " ".join(parts) def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: """Return security warnings for an MCP server entry. Empty return means the entry is not suspicious. This is intentionally not a whitelist: legitimate local MCPs can still use custom commands, Python scripts, npx, uvx, etc. We block three narrow shapes only: * a known hermes-0day IOC anywhere in command/args/env (hardcoded blocklist); * a shell interpreter whose inline script invokes network egress (#45620); * a shell interpreter whose inline script writes to an OS persistence surface (June 2026 hermes-0day SSH/PAM/sudoers/cron shape). """ if not isinstance(entry, dict): return [] issues: list[str] = [] # 1. Hardcoded IOC blocklist — applies regardless of command shape. flat = _entry_text(entry) for ioc in _IOC_SUBSTRINGS: if ioc in flat: issues.append( f"MCP server '{name}' contains a known hermes-0day " f"indicator-of-compromise ('{ioc}')" ) # One IOC is enough to refuse; don't leak the full match list. return issues command = entry.get("command") basename = _command_basename(command) if basename not in _SHELL_INTERPRETERS: return issues script = _inline_script(entry.get("args")) if not script: return issues # 2. Network exfiltration shape. if _EGRESS_PATTERN.search(script): issue = ( f"MCP server '{name}' uses shell interpreter '{command}' with " f"network egress in args" ) if _EXFIL_HINT_PATTERN.search(script): issue += " and exfiltration-shaped arguments" issues.append(issue) # 3. OS persistence shape (SSH key / PAM / sudoers / cron / rc files). if _PERSISTENCE_PATTERN.search(script): issues.append( f"MCP server '{name}' uses shell interpreter '{command}' to write " f"to an OS persistence surface (SSH keys / PAM / sudoers / cron / " f"shell rc) — this is the hermes-0day backdoor shape, not a real " f"MCP server" ) return issues def is_mcp_server_entry_suspicious(name: str, entry: dict[str, Any]) -> bool: return bool(validate_mcp_server_entry(name, entry))