"""Security checks for user-configured MCP server entries. 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: """ 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