security_checklist.py

script

← Back to skill

Content hash: 6d95e18650ef9a3171420d92b33d1b4cca0e42fe56b6b2dd7dd090aaec06ccea
#!/usr/bin/env python3
"""Security validation helper for MCP server configurations.

Checks auth patterns, secret hygiene, least-privilege tool design,
and common exposure vectors.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from typing import Any


@dataclass
class ToolDef:
    name: str
    description: str
    is_mutation: bool = False  # write/delete/publish
    requires_auth: bool = False
    scope: str = "global"  # "personal" | "team" | "global"


@dataclass
class SecurityFinding:
    severity: str  # "critical" | "high" | "medium" | "low"
    tool: str
    message: str


def audit_server(tools: list[ToolDef], config: dict[str, Any]) -> list[SecurityFinding]:
    """Run the MCP security checklist against a server's tool surface."""
    findings: list[SecurityFinding] = []

    for tool in tools:
        # Critical: mutation without auth
        if tool.is_mutation and not tool.requires_auth:
            findings.append(SecurityFinding(
                "critical", tool.name,
                "Mutation tool has NO auth requirement. Any caller can invoke it.",
            ))

        # Critical: global-scope mutation by regular agents
        if tool.is_mutation and tool.scope == "global" and "super" not in tool.description.lower():
            findings.append(SecurityFinding(
                "critical", tool.name,
                "Global-scope mutation may be accessible to non-admin agents.",
            ))

        # High: tool that echoes user input back (potential injection vector)
        if any(kw in tool.name.lower() for kw in ("echo", "reflect", "reply")):
            findings.append(SecurityFinding(
                "high", tool.name,
                f"Tool name suggests it echoes input - potential output-trust issue.",
            ))

    # Auth header check
    if not config.get("require_header_auth", False):
        findings.append(SecurityFinding(
            "high", "server",
            "No header-based auth requirement. Agent keys as tool args only is insufficient.",
        ))

    # Transport check
    if config.get("transport") == "http" and not config.get("auth_enabled", False):
        findings.append(SecurityFinding(
            "critical", "server",
            "HTTP transport with no auth: tools are internet-accessible without credentials.",
        ))

    # Secret hygiene
    if config.get("log_args", False) and config.get("log_full_body", False):
        findings.append(SecurityFinding(
            "high", "server",
            "Logging full tool args may leak secrets (API keys, tokens).",
        ))

    return findings


# ── Demo ───────────────────────────────────────────────────────────────────

def main() -> None:
    # A realistic but flawed toolset
    tools = [
        ToolDef("publish_skill", "Publish a skill to the global store", is_mutation=True, scope="global"),
        ToolDef("delete_skill", "Delete a skill permanently", is_mutation=True, requires_auth=True, scope="personal"),
        ToolDef("get_skill", "Read skill content", is_mutation=False),
        ToolDef("search_skills", "Search skill library", is_mutation=False),
    ]

    config = {
        "transport": "http",
        "auth_enabled": False,      # BAD
        "require_header_auth": False, # BAD
        "log_args": True,
        "log_full_body": True,       # BAD
    }

    print("=== MCP Security Audit ===\n")
    for f in audit_server(tools, config):
        print(f"[{f.severity:8s}] {f.tool:20s} - {f.message}")

    print("\nHardening checklist:")
    print("  1. Require auth header ON EVERY mutation tool")
    print("  2. Scope tools: personal < team < global")
    print("  3. Enforce ownership: only owner may update/delete")
    print("  4. Never log raw keys; store only hashes")
    print("  5. HTTP transport ALWAYS requires auth + rate limiting")
    print("  6. Validate tool args server-side (never trust the model)")


if __name__ == "__main__":
    main()