From fd49e28d052a9e41463b4d7fbe0620e0ca1e48bc Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Sun, 29 Mar 2026 19:51:51 +0200 Subject: [PATCH] Initial commit --- .env.example | 35 + .gitignore | 16 + Dockerfile | 47 ++ certs/.gitkeep | 0 docker-compose.yml | 55 ++ docs/HLD.docx | Bin 0 -> 48728 bytes docs/HLD.md | 529 ++++++++++++ docs/LLD.docx | Bin 0 -> 52906 bytes docs/LLD.md | 900 ++++++++++++++++++++ docs/MANUAL.docx | Bin 0 -> 50019 bytes docs/MANUAL.md | 966 ++++++++++++++++++++++ docs/md_to_docx.py | 343 ++++++++ pyproject.toml | 41 + src/mcp_privileged/__init__.py | 1 + src/mcp_privileged/audit.py | 228 +++++ src/mcp_privileged/auth.py | 94 +++ src/mcp_privileged/config.py | 122 +++ src/mcp_privileged/cyberark/__init__.py | 0 src/mcp_privileged/cyberark/client.py | 245 ++++++ src/mcp_privileged/cyberark/server.py | 154 ++++ src/mcp_privileged/database/__init__.py | 0 src/mcp_privileged/database/server.py | 356 ++++++++ src/mcp_privileged/main.py | 105 +++ src/mcp_privileged/powershell/__init__.py | 0 src/mcp_privileged/powershell/server.py | 214 +++++ src/mcp_privileged/secret_store.py | 176 ++++ src/mcp_privileged/ssh/__init__.py | 0 src/mcp_privileged/ssh/server.py | 187 +++++ tests/__init__.py | 0 tests/conftest.py | 170 ++++ tests/test_auth.py | 72 ++ tests/test_cyberark_client.py | 165 ++++ tests/test_database_server.py | 303 +++++++ tests/test_integration.py | 330 ++++++++ tests/test_powershell_server.py | 227 +++++ tests/test_secret_store.py | 100 +++ tests/test_ssh_server.py | 291 +++++++ 37 files changed, 6472 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 certs/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docs/HLD.docx create mode 100644 docs/HLD.md create mode 100644 docs/LLD.docx create mode 100644 docs/LLD.md create mode 100644 docs/MANUAL.docx create mode 100644 docs/MANUAL.md create mode 100644 docs/md_to_docx.py create mode 100644 pyproject.toml create mode 100644 src/mcp_privileged/__init__.py create mode 100644 src/mcp_privileged/audit.py create mode 100644 src/mcp_privileged/auth.py create mode 100644 src/mcp_privileged/config.py create mode 100644 src/mcp_privileged/cyberark/__init__.py create mode 100644 src/mcp_privileged/cyberark/client.py create mode 100644 src/mcp_privileged/cyberark/server.py create mode 100644 src/mcp_privileged/database/__init__.py create mode 100644 src/mcp_privileged/database/server.py create mode 100644 src/mcp_privileged/main.py create mode 100644 src/mcp_privileged/powershell/__init__.py create mode 100644 src/mcp_privileged/powershell/server.py create mode 100644 src/mcp_privileged/secret_store.py create mode 100644 src/mcp_privileged/ssh/__init__.py create mode 100644 src/mcp_privileged/ssh/server.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_cyberark_client.py create mode 100644 tests/test_database_server.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_powershell_server.py create mode 100644 tests/test_secret_store.py create mode 100644 tests/test_ssh_server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0f5352 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ────────────────────────────────────────────── +# MCP Privileged Access Service — Configuration +# Copy to .env and fill in values. +# NEVER commit .env to source control. +# ────────────────────────────────────────────── + +# ── Service ─────────────────────────────────── +MCP_HOST=0.0.0.0 +MCP_PORT=8443 + +# Comma-separated API keys issued to Claude Code clients +MCP_API_KEYS=changeme-key-1,changeme-key-2 + +# ── Secret Handle Store ──────────────────────── +# Seconds a handle remains valid after creation +HANDLE_TTL_SECONDS=300 +# Invalidate handle after first resolve (true/false) +HANDLE_SINGLE_USE=true + +# ── CyberArk CCP ────────────────────────────── +CYBERARK_CCP_URL=https://cyberark.internal/AIMWebService/api/Accounts +# AppID registered in CyberArk for this service +CYBERARK_APP_ID=MCP-Privileged-Service +# Path to CA bundle for verifying the CCP TLS certificate +# Set to "false" to disable verification (NOT recommended for production) +CYBERARK_VERIFY_SSL=/etc/ssl/certs/ca-certificates.crt + +# ── CyberArk mTLS (future — leave empty for IP allowlist mode) ── +CYBERARK_CERT_PFX_PATH= +CYBERARK_CERT_PFX_PASSWORD= + +# ── Audit Logging ───────────────────────────── +# "json" for structured log shipping, "console" for human-readable +LOG_FORMAT=json +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..773e282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +*.pfx +*.p12 +*.pem +*.key +certs/* +!certs/.gitkeep + +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +.venv/ +.pytest_cache/ +.coverage +htmlcov/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..285f098 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# ── Stage 1: build wheel ────────────────────────────────────────────────────── +FROM python:3.11-slim AS builder + +WORKDIR /build + +# Install build tools +RUN pip install --no-cache-dir hatchling + +COPY pyproject.toml . +COPY src/ src/ + +RUN pip wheel --no-cache-dir --wheel-dir /wheels . + + +# ── Stage 2: runtime image ──────────────────────────────────────────────────── +FROM python:3.11-slim + +# System packages needed at runtime: +# unixodbc-dev — pyodbc SQL Server support +# ca-certificates — TLS verification against internal CAs +RUN apt-get update && apt-get install -y --no-install-recommends \ + unixodbc \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the pre-built wheel and all dependencies +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir --no-index --find-links /wheels mcp-privileged \ + && rm -rf /wheels + +# Non-root service user +RUN useradd --system --no-create-home --shell /usr/sbin/nologin mcpuser + +# Mount-points for runtime secrets (provided by docker secret / volume) +RUN install -d -o mcpuser -g mcpuser /run/secrets /app/certs + +USER mcpuser + +EXPOSE 8443 + +# Health check — lightweight GET /health (no auth required) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8443/health')" + +ENTRYPOINT ["mcp-privileged"] diff --git a/certs/.gitkeep b/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..013a6d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +# docker-compose.yml — local development / integration testing +# +# Usage: +# docker compose up --build +# +# The service reads config from the .env file (copy .env.example → .env first). +# Certs (for mTLS) are volume-mounted from ./certs/. + +version: "3.9" + +services: + mcp-privileged: + build: + context: . + dockerfile: Dockerfile + image: mcp-privileged:dev + container_name: mcp-privileged + ports: + - "8443:8443" + env_file: + - .env + volumes: + # Mount TLS certs if mTLS is configured + - ./certs:/app/certs:ro + restart: unless-stopped + # Override log format for local readability + environment: + LOG_FORMAT: console + LOG_LEVEL: DEBUG + + # ── Optional: local test databases ───────────────────────────────────────── + postgres: + image: postgres:16-alpine + container_name: test-postgres + environment: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: testdb + ports: + - "5432:5432" + profiles: + - db # only started with: docker compose --profile db up + + mysql: + image: mysql:8-debian + container_name: test-mysql + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + MYSQL_DATABASE: testdb + ports: + - "3306:3306" + profiles: + - db diff --git a/docs/HLD.docx b/docs/HLD.docx new file mode 100644 index 0000000000000000000000000000000000000000..f82a5741ec7a33dda3216ee3b0dd4e58e6bb0700 GIT binary patch literal 48728 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%0DW|ANS12^JcB|9L)!Ps=5RNgOcpqd@J0r6ie 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 O4ln{YkDa#XPyY{a%kXOe literal 0 HcmV?d00001 diff --git a/docs/HLD.md b/docs/HLD.md new file mode 100644 index 0000000..0010cb9 --- /dev/null +++ b/docs/HLD.md @@ -0,0 +1,529 @@ +# High-Level Design +# MCP Privileged Access Service + +**Version:** 1.1 +**Date:** 2026-03-28 +**Status:** Production-ready + +--- + +## Table of Contents + +0. [What is MCP? A Primer](#0-what-is-mcp-a-primer) +1. [Purpose & Scope](#1-purpose--scope) +2. [System Context](#2-system-context) +3. [Architecture Principles](#3-architecture-principles) +4. [Component Overview](#4-component-overview) +5. [Authentication & Authorization Model](#5-authentication--authorization-model) +6. [The Secret Handle Pattern](#6-the-secret-handle-pattern) +7. [Data Flow — Key Use Cases](#7-data-flow--key-use-cases) +8. [Deployment Architecture](#8-deployment-architecture) +9. [Technology Choices](#9-technology-choices) +10. [Security Architecture Summary](#10-security-architecture-summary) +11. [Future Roadmap](#11-future-roadmap) + +--- + +## 0. What is MCP? A Primer + +> **Learning note:** This section explains the MCP concept from first principles before we dive into this specific service. Skip to Section 1 if you are already familiar with the protocol. + +### The core problem MCP solves + +A large language model (LLM) like Claude is, at its heart, a text-in / text-out system. On its own it cannot *do* anything in the world — it can only describe what it would do. The challenge is: how do you give an AI assistant the ability to take real actions (read a file, query a database, run a command) in a controlled, auditable, and standardised way? + +Before MCP, every team solved this differently. Some embedded shell calls directly in prompts; others built bespoke REST wrappers. There was no common contract between the AI and the tools it called. + +**MCP (Model Context Protocol)** is Anthropic's open standard that defines exactly that contract. + +--- + +### The three primitives + +MCP defines three building blocks that a server can expose to a model: + +| Primitive | What it is | Analogy | +|-----------|-----------|---------| +| **Tool** | A callable function the model can invoke | An API endpoint / RPC call | +| **Resource** | A piece of data the model can read | A file or database row | +| **Prompt** | A reusable prompt template | A macro or named query | + +> **Learning note:** This service uses only **Tools**. Tools are the most important primitive for *agentic* use cases — cases where the model takes actions, not just answers questions. Resources and Prompts are useful but less common in automation pipelines. + +--- + +### How a tool call works end-to-end + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ USER "Check disk usage on web01" │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ user message + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ CLAUDE (the model) │ +│ Reads the list of available tools (JSON Schema descriptions). │ +│ Decides: "I need ssh_execute to answer this." │ +│ Emits a tool_use block in its response: │ +│ { "name": "ssh_execute", │ +│ "input": { "host": "web01", "command": "df -h", ... } } │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ tool_use request (JSON-RPC over HTTP/SSE) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ MCP SERVER (this service) │ +│ Receives the JSON-RPC call. │ +│ Executes the Python function ssh_execute(...). │ +│ Returns a tool_result: { "content": "Filesystem Size Used…" } │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ tool_result (text) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ CLAUDE (the model) │ +│ Incorporates the tool result into its context. │ +│ Generates a final human-readable answer. │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ assistant message + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ USER "web01: 18G used of 50G (36%)" │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +> **Learning note:** The model **never executes code itself**. It only emits a structured request saying "please call this tool with these arguments." The MCP server is the only thing that touches real infrastructure. This separation is fundamental to safety — you can audit, rate-limit, and authorise every action at the server layer without modifying the model. + +--- + +### Transport: SSE over HTTP + +MCP uses **Server-Sent Events (SSE)** as its default transport. The client (Claude Code) opens a persistent HTTP connection to the server. The server streams JSON-RPC messages back as SSE events. + +> **Learning note:** Why SSE and not WebSockets? SSE is unidirectional (server → client) and works over plain HTTP/1.1 with no protocol upgrade. This makes it firewall-friendly and easy to put behind standard reverse proxies like nginx. The request direction (client → server) still uses normal HTTP POST. + +--- + +### FastMCP: the Python framework + +Raw MCP requires implementing a JSON-RPC server, describing tools in JSON Schema, and handling SSE streams. **FastMCP** (Anthropic's Python library) removes all of that boilerplate: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("my-server") + +@mcp.tool(description="Add two numbers") +async def add(a: int, b: int) -> int: + return a + b +``` + +FastMCP introspects the Python type annotations and generates the JSON Schema automatically. The `@mcp.tool()` decorator registers the function — the function itself is just a normal `async def`. + +> **Learning note:** This is exactly how all four MCP servers in this service are built. The tool functions (`get_credential`, `ssh_execute`, `ps_execute`, `db_query`) are plain Python async functions. The MCP protocol wrapping is invisible to the implementation code. You can call them directly in unit tests without any MCP machinery at all — which is why the test suite can be so simple. + +--- + +### Context injection + +FastMCP injects a `Context` object as the `ctx` parameter of every tool. You do not pass it yourself — the framework supplies it automatically when the tool is called over the MCP protocol. + +```python +async def ssh_execute(host: str, command: str, ctx: Context, ...) -> str: + await ctx.info(f"Connecting to {host}") # progress notification to the caller + await ctx.error("Something went wrong") # error notification +``` + +`ctx.info()` and `ctx.error()` send notifications back to the client *during* tool execution, before the final result is returned. This is how Claude Code shows "Connecting to web01..." in its status bar while a long-running command is in progress. + +> **Learning note:** In unit tests, `ctx` is a `MagicMock`. The tests assert on `ctx.info.call_args` and `ctx.error.call_args` to verify the right status messages were emitted — without any real MCP transport being involved. + +--- + +### Multi-server composition + +A single Python process can host **multiple independent MCP servers**, each mounted at a different URL path on a shared FastAPI application: + +``` +FastAPI app +├── /mcp/cyberark ← FastMCP("cyberark") — get_credential, list_safes +├── /mcp/ssh ← FastMCP("ssh") — ssh_execute +├── /mcp/powershell ← FastMCP("powershell")— ps_execute +└── /mcp/database ← FastMCP("database") — db_query +``` + +Claude Code is configured with four separate MCP server entries, each pointing to one of these paths. From Claude's perspective they appear as four separate servers, but they share a single process, a single secret store, and a single audit log stream. + +> **Learning note:** Mounting multiple FastMCP instances on one FastAPI app via `app.mount(path, mcp.sse_app())` is the standard pattern for building multi-capability MCP services. The alternative — one process per server — would require inter-process communication to share the secret store, which adds complexity with no security benefit. + +--- + +## 1. Purpose & Scope + +The MCP Privileged Access Service enables Claude (Anthropic's AI assistant) to execute privileged operations on enterprise infrastructure — Linux servers via SSH, Windows servers via PowerShell/WinRM, and databases — using credentials managed by CyberArk Privileged Access Management (PAM). + +**The fundamental security guarantee:** + +> The AI model (Claude) **never sees the actual password** at any point in the workflow. Credentials are fetched from CyberArk, held in RAM behind an opaque token, and used directly for the target connection — all within the service boundary. + +**Scope includes:** +- Retrieving credentials from CyberArk Central Credential Provider (CCP) +- Executing shell commands on Linux/Unix hosts via SSH +- Executing PowerShell scripts on Windows hosts via WinRM +- Running SQL queries on PostgreSQL, MySQL, and SQL Server databases +- Structured audit logging of all privileged operations +- API key authentication for Claude Code clients + +**Scope excludes:** +- User interface or dashboard +- Credential rotation or lifecycle management (handled by CyberArk) +- Session recording (handled by CyberArk PSM if required) +- Multi-tenancy (single-tenant service per deployment) + +--- + +## 2. System Context + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ OPERATOR / SECURITY TEAM │ +│ • Provisions CyberArk safes & AppID │ +│ • Issues MCP API keys to Claude Code clients │ +│ • Reviews structured audit logs │ +└──────────────────────┬───────────────────────────────────────────────┘ + │ configure + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ CLAUDE CODE (client) │ +│ Claude Desktop / VS Code / CLI │ +│ - Sends MCP tool calls over HTTPS with X-API-Key header │ +│ - Receives tool results (output, exit codes, query rows) │ +│ - NEVER receives actual passwords │ +└──────────────────────┬───────────────────────────────────────────────┘ + │ HTTPS + API Key (JSON-RPC / MCP protocol) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ MCP PRIVILEGED ACCESS SERVICE (this system) │ +│ ┌─────────────┐ ┌──────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ │ +│ │ CyberArk │ │ SSH │ │PowerShell│ │Database│ │ Auth + │ │ +│ │ MCP │ │ MCP │ │ MCP │ │ MCP │ │ Audit │ │ +│ └──────┬──────┘ └──┬───┘ └────┬─────┘ └───┬────┘ └─────────┘ │ +│ │ │ │ │ │ +│ └────────────┴────────────┴─────────────┘ │ +│ Secret Store (RAM) │ +└───┬──────────────┬──────────────┬──────────────┬─────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│CyberArk │Linux/Unix│ │ Windows │ │PostgreSQL│ +│ CCP │ │ Hosts │ │ Hosts │ │ MySQL │ +│(HTTPS)│ │ (SSH) │ │ (WinRM) │ │SQL Server│ +└───────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +--- + +## 3. Architecture Principles + +### P1 — Zero password exposure to the LLM +Passwords flow from CyberArk → RAM → target connection. At no stage does a password appear in an MCP tool response, log message, or error message. This is enforced in code, not by policy alone. + +### P2 — Short-lived, single-use credential handles +A credential fetched from CyberArk is wrapped in a cryptographically random handle (`secret://` + 32-char hex). The handle: +- Expires after a configurable TTL (default 5 minutes) +- Is invalidated on first use (default `HANDLE_SINGLE_USE=true`) +- Lives only in process RAM — never written to disk or network + +### P3 — Full audit trail +Every credential fetch, handle resolution, SSH execution, PowerShell execution, and database query is recorded in structured JSON logs. Passwords and output data are **never** included in audit events. + +### P4 — Defence in depth +Multiple independent security layers: +1. Network: service only reachable from permitted IP ranges (firewall / VPC) +2. Transport: HTTPS with valid TLS certificate +3. Application: API key authentication on every request +4. Credential: CyberArk AppID + IP allowlist (or mTLS) +5. Handle: short TTL + single use +6. Code: `SecretStr` wrapper prevents accidental password serialisation + +### P5 — Stateless compute, stateful secrets in RAM only +No database, no disk state. The secret store is an in-memory dict with an asyncio lock. Service restart invalidates all handles (safe failure mode — operators must re-fetch). + +### P6 — Explicit over implicit +Every configuration value is explicit (`settings.*`), every dependency is injected at startup (lifespan), and every module imports only what it needs. No global mutable state except the two intentional singletons (`secret_store`, `cyberark_client`). + +--- + +## 4. Component Overview + +### 4.1 Foundation Layer + +| Component | File | Role | +|-----------|------|------| +| Configuration | `config.py` | Single pydantic-settings model; reads from env / `.env` file | +| Secret Store | `secret_store.py` | In-RAM handle store with TTL, single-use, and background sweeper | +| Auth Middleware | `auth.py` | Starlette middleware; validates API key on all `/mcp/*` routes | +| Audit Logger | `audit.py` | Structured structlog events; one function per audit event type | +| Service Entry Point | `main.py` | FastAPI app assembly, lifespan wiring, MCP server mounting | + +### 4.2 MCP Servers + +| Server | Mount Path | Tool(s) | Protocol | Auth to target | +|--------|-----------|---------|----------|----------------| +| CyberArk | `/mcp/cyberark` | `get_credential`, `list_safes` | HTTPS REST | IP allowlist / mTLS | +| SSH | `/mcp/ssh` | `ssh_execute` | SSH (asyncssh) | Password from handle | +| PowerShell | `/mcp/powershell` | `ps_execute` | WinRM (pypsrp) | Password from handle | +| Database | `/mcp/database` | `db_query` | asyncpg / aiomysql / pyodbc | Password from handle | + +Each MCP server is an independent `FastMCP` instance mounted as a sub-application on the shared FastAPI app. They share only two objects: `secret_store` (to resolve handles) and `settings` (configuration). + +--- + +## 5. Authentication & Authorization Model + +### Client → Service (inbound) + +``` +Claude Code client + │ + │ HTTP request to /mcp//... + │ Header: X-API-Key: + │ OR + │ Header: Authorization: Bearer + │ + ▼ + ApiKeyMiddleware + │ + ├── Path starts with /mcp/ ? + │ NO → pass through (health check, etc.) + │ YES → validate key against settings.mcp_api_keys + │ INVALID → 401 + audit log + │ VALID → continue to MCP handler +``` + +Multiple API keys are supported (comma-separated `MCP_API_KEYS`). Keys can be rotated by removing old keys and adding new ones, with no restart required if using a future key-reload mechanism. + +### Service → CyberArk (outbound) + +**Mode 1: IP Allowlist (current default)** +- The service makes HTTPS GET requests to the CCP REST API +- CyberArk trusts the caller based on source IP +- The AppID (`CYBERARK_APP_ID`) identifies the application in CyberArk policy + +**Mode 2: mTLS (future)** +- A PFX certificate file is loaded at startup +- The TLS client certificate is attached to every CCP request +- CyberArk validates the certificate in addition to (or instead of) IP + +### Service → Target Systems (outbound) + +| Target | Auth method | Credentials from | +|--------|-------------|-----------------| +| SSH hosts | Password or key | Secret handle → `asyncssh.connect(password=...)` | +| WinRM hosts | NTLM / Basic | Secret handle → `WSMan(password=...)` | +| Databases | Native DB auth | Secret handle → driver connect call | + +--- + +## 6. The Secret Handle Pattern + +This is the central security innovation of the service. It solves the problem: *How does an AI model invoke privileged operations without ever knowing the password?* + +``` +Step 1 — Credential fetch +────────────────────────── +Claude calls: get_credential(safe="PROD-LINUX", object_name="svc_root") + +Service: + 1. Calls CyberArk CCP REST API + 2. Receives { "UserName": "root", "Content": "P@ssword123", ... } + 3. Calls secret_store.store("root", "P@ssword123") + → stores in RAM as _Entry with a random 32-char hex handle_id + → returns handle = "secret://a3f9c2e1b8d7..." + 4. Returns to Claude: "Handle: secret://a3f9c2e1... TTL: 300s" + PASSWORD IS NEVER IN THIS RETURN VALUE + +Step 2 — Privileged operation +────────────────────────────── +Claude calls: ssh_execute(host="server01", command="df -h", + secret_handle="secret://a3f9c2e1b8d7...") + +Service: + 1. Calls secret_store.resolve("secret://a3f9c2e1b8d7...") + → checks TTL and single-use flag + → if valid: returns ("root", "P@ssword123") and deletes handle + 2. Calls asyncssh.connect("server01", username="root", password="P@ssword123") + 3. Runs command, collects output + 4. Deletes password variable (del password) + 5. Returns: "Exit code: 0\nstdout:\n/dev/sda1 50G 10G 40G 20% /" + PASSWORD IS NEVER IN THIS RETURN VALUE + +Step 3 — Handle is gone +──────────────────────── +If Claude tries to reuse the same handle: + secret_store.resolve(...) raises KeyError("Handle already consumed") + → Claude must call get_credential again for the next operation +``` + +**Handle lifecycle state machine:** + +``` + store() +CREATED ────────────────► ACTIVE + │ + ┌────────┴────────┐ + │ │ + resolve() TTL expired + (single_use=True) (sweeper task) + │ │ + ▼ ▼ + CONSUMED EXPIRED + (deleted) (deleted) +``` + +--- + +## 7. Data Flow — Key Use Cases + +### 7.1 SSH Command Execution + +``` +Claude CyberArk MCP SecretStore SSH MCP Linux Host + │ │ │ │ │ + │ get_credential │ │ │ │ + │────────────────►│ │ │ │ + │ │ GET CCP REST │ │ │ + │ │──────────────────────────────────────────────►│(CyberArk) + │ │◄─────────────────────────────────────────────-│ + │ │ store(user,pw)│ │ │ + │ │──────────────►│ │ │ + │ │◄── handle ────│ │ │ + │◄── handle ──────│ │ │ │ + │ │ │ │ │ + │ ssh_execute(handle) │ │ │ + │─────────────────────────────────────────────────► │ + │ │ │ resolve(handle│ │ + │ │ │◄──────────────│ │ + │ │ │──(user,pw)───►│ │ + │ │ │ │ SSH connect │ + │ │ │ │──────────────►│ + │ │ │ │◄── output ───-│ + │ │ │ │ del password │ + │◄─────────────────────────────────────────────────output────────│ +``` + +### 7.2 Database Query + +Identical flow to SSH, substituting `db_query` for `ssh_execute` and the target database driver for asyncssh. + +### 7.3 PowerShell Execution + +The WinRM flow differs in one aspect: pypsrp is synchronous, so the call is offloaded to a thread-pool executor while the asyncio event loop continues serving other requests. + +``` + asyncio event loop Thread pool executor + ────────────────── ──────────────────── + resolve handle + await run_in_executor(None, _run_ps_sync, ...) ──────► _run_ps_sync() + [event loop free to handle other requests] WSMan() + RunspacePool() + ps.invoke() + ◄────────────────────────────────────────────── return output + del password + return result +``` + +--- + +## 8. Deployment Architecture + +### Recommended (Docker on a hardened VM) + +``` + ┌──────────────────────────────────────────────┐ + │ Hardened VM (e.g., Ubuntu 22.04) │ + │ │ + │ ┌─────────────────────────────────────┐ │ + │ │ Docker container │ │ + │ │ Image: mcp-privileged:1.0 │ │ + │ │ User: mcpuser (non-root) │ │ + │ │ Port: 8443 (internal) │ │ + │ └──────────────┬──────────────────────┘ │ + │ │ │ + │ ┌──────────────▼──────────────────────┐ │ + │ │ Reverse proxy (nginx / Caddy) │ │ + │ │ TLS termination │ │ + │ │ Port: 443 (external) │ │ + │ └────────────────────────────────────── │ + └──────────────────────────────────────────────┘ + │ + │ Firewall: only Claude Code source IPs allowed +``` + +### Network segmentation requirements + +| Connection | Inbound to | Source | Port | +|-----------|------------|--------|------| +| Claude Code → Service | Service host | Claude Code client IPs | 443 (HTTPS) | +| Service → CyberArk CCP | CyberArk | Service host IP | 443 (HTTPS) | +| Service → SSH targets | Linux hosts | Service host IP | 22 (or custom) | +| Service → WinRM targets | Windows hosts | Service host IP | 5985/5986 | +| Service → Databases | DB servers | Service host IP | 5432/3306/1433 | + +### Health check + +`GET /health` returns `{"status": "ok"}` with no authentication. Suitable for load balancer and container health probes. + +--- + +## 9. Technology Choices + +| Technology | Choice | Rationale | +|-----------|--------|-----------| +| Web framework | FastAPI | Async-native, excellent OpenAPI support, Starlette middleware | +| MCP framework | FastMCP (mcp[server]) | Official Python MCP SDK; Streamable HTTP transport | +| HTTP client | httpx | Async, connection pooling, easy mock transport for tests | +| SSH | asyncssh | Pure-Python async SSH2; no subprocess dependency | +| WinRM | pypsrp | Python PowerShell Remoting Protocol; most complete WinRM library | +| PostgreSQL | asyncpg | Fastest async Postgres driver; native protocol | +| MySQL | aiomysql | Async MySQL driver | +| SQL Server | pyodbc | Standard ODBC; requires Microsoft ODBC Driver 18 on host | +| Config | pydantic-settings | Type-safe config; reads from env + `.env`; validates at startup | +| Logging | structlog | Structured JSON output; easy log shipping; context vars | +| Crypto | cryptography | PFX parsing for mTLS; well-maintained | +| Runtime | Python 3.11 | asyncio improvements, `tomllib`, `ExceptionGroup`, slots dataclasses | +| Container | Docker (multi-stage) | Small runtime image; non-root user; no build tools in production | + +--- + +## 10. Security Architecture Summary + +| Control | Implementation | Protects Against | +|---------|---------------|-----------------| +| TLS in transit | HTTPS everywhere (CCP, service) | Eavesdropping, MITM | +| API key auth | `ApiKeyMiddleware` on all `/mcp/*` | Unauthorised tool calls | +| CyberArk AppID | Registered in CyberArk policy | Unauthorised credential access | +| IP allowlist (CyberArk) | CyberArk trusted-net config | Rogue callers to CCP | +| mTLS (future) | PFX cert on CCP requests | Stronger caller identity | +| Secret handle | Opaque token, not password | Password exposure to LLM | +| Single-use handle | `handle_single_use=True` | Credential replay | +| TTL on handle | Default 300s | Handle leakage window | +| RAM-only storage | `SecretStore` dict, no disk I/O | Credential at-rest exposure | +| `SecretStr` wrapper | Pydantic `SecretStr` | Accidental log/repr of password | +| `del password` | Explicit deletion after use | Password in heap dumps | +| Audit log (no password) | structlog, explicit field list | Credential in log files | +| Non-root container | `USER mcpuser` in Dockerfile | Container escape impact | +| Output limits | 50 KB per stream, 1000 DB rows | Context flooding / DoS | + +--- + +## 11. Future Roadmap + +| Item | Priority | Description | +|------|----------|-------------| +| mTLS for CyberArk | High | Config already present; needs PFX cert provisioning | +| API key rotation without restart | Medium | Watch env file or use a config reload endpoint | +| SSH key-based auth | Medium | Support `asyncssh` with private key from CyberArk | +| Kerberos/NTLM for WinRM | Medium | Currently NTLM; Kerberos for domain environments | +| Connection pooling (SSH) | Low | Reuse SSH connections for repeated calls to same host | +| Multi-tenant API keys | Low | Map API keys to CyberArk AppIDs for key-per-team isolation | +| Metrics endpoint | Low | Prometheus `/metrics` for connection counts, handle stats | +| Session recording integration | Low | Forward SSH output to CyberArk PSM or a SIEM | diff --git a/docs/LLD.docx b/docs/LLD.docx new file mode 100644 index 0000000000000000000000000000000000000000..50b9151fbe741c3486a9b325fca39400f4ed9b9b GIT binary patch literal 52906 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 literal 0 HcmV?d00001 diff --git a/docs/LLD.md b/docs/LLD.md new file mode 100644 index 0000000..452f1bc --- /dev/null +++ b/docs/LLD.md @@ -0,0 +1,900 @@ +# Low-Level Design +# MCP Privileged Access Service + +**Version:** 1.0 +**Date:** 2026-03-28 +**Status:** Production-ready + +--- + +## Table of Contents + +1. [Module Structure](#1-module-structure) +2. [Foundation Modules](#2-foundation-modules) + - 2.1 config.py + - 2.2 secret_store.py + - 2.3 auth.py + - 2.4 audit.py + - 2.5 main.py +3. [CyberArk MCP](#3-cyberark-mcp) +4. [SSH MCP](#4-ssh-mcp) +5. [PowerShell MCP](#5-powershell-mcp) +6. [Database MCP](#6-database-mcp) +7. [MCP Tool API Reference](#7-mcp-tool-api-reference) +8. [Data Models](#8-data-models) +9. [Configuration Reference](#9-configuration-reference) +10. [Error Handling Matrix](#10-error-handling-matrix) +11. [Audit Event Catalog](#11-audit-event-catalog) +12. [Test Strategy](#12-test-strategy) + +--- + +## 1. Module Structure + +``` +src/mcp_privileged/ +├── __init__.py +├── config.py ← All settings; read once at import time +├── secret_store.py ← In-RAM handle store + background sweeper +├── auth.py ← API key middleware +├── audit.py ← Structured log helpers +├── main.py ← FastAPI app assembly + lifespan +│ +├── cyberark/ +│ ├── __init__.py +│ ├── client.py ← CCP REST client (httpx) +│ └── server.py ← FastMCP server; get_credential, list_safes tools +│ +├── ssh/ +│ ├── __init__.py +│ └── server.py ← FastMCP server; ssh_execute tool +│ +├── powershell/ +│ ├── __init__.py +│ └── server.py ← FastMCP server; ps_execute tool +│ +└── database/ + ├── __init__.py + └── server.py ← FastMCP server; db_query tool +``` + +**Dependency graph (no cycles):** + +``` +main.py + ├── config.py (leaf) + ├── audit.py ← config.py + ├── auth.py ← config.py, audit.py + ├── secret_store.py ← config.py, audit.py + ├── cyberark/ + │ ├── client.py ← config.py, audit.py + │ └── server.py ← cyberark/client.py, secret_store.py, audit.py + ├── ssh/server.py ← config.py, secret_store.py, audit.py + ├── powershell/server.py ← config.py, secret_store.py, audit.py + └── database/server.py ← config.py, secret_store.py, audit.py +``` + +--- + +## 2. Foundation Modules + +### 2.1 config.py + +**Class:** `Settings(BaseSettings)` +**Singleton:** `settings = Settings()` — imported everywhere as `from mcp_privileged.config import settings` + +The settings object is created once when the module is first imported. If any required value is missing or invalid, pydantic raises a `ValidationError` at startup — fail fast. + +#### Settings groups + +**Service** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `mcp_host` | `MCP_HOST` | `0.0.0.0` | `str` | Bind address for uvicorn | +| `mcp_port` | `MCP_PORT` | `8443` | `int` | Listen port | +| `mcp_api_keys_raw` | `MCP_API_KEYS` | *(required)* | `str` | Comma-separated API keys — access via the `mcp_api_keys` property | + +**Secret Handle Store** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `handle_ttl_seconds` | `HANDLE_TTL_SECONDS` | `300` | `int` (30–3600) | Handle expiry | +| `handle_single_use` | `HANDLE_SINGLE_USE` | `True` | `bool` | Invalidate on first resolve | + +**CyberArk CCP** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `cyberark_ccp_url` | `CYBERARK_CCP_URL` | — | `str` | Full CCP REST endpoint URL | +| `cyberark_app_id` | `CYBERARK_APP_ID` | — | `str` | AppID registered in CyberArk | +| `cyberark_verify_ssl` | `CYBERARK_VERIFY_SSL` | system CAs | `str` | `"false"`, `"true"`, or CA path | +| `cyberark_cert_pfx_path` | `CYBERARK_CERT_PFX_PATH` | `None` | `Path\|None` | mTLS client cert (PFX) | +| `cyberark_cert_pfx_password` | `CYBERARK_CERT_PFX_PASSWORD` | `None` | `str\|None` | PFX password | + +**PowerShell / WinRM** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `winrm_auth` | `WINRM_AUTH` | `ntlm` | `str` | `ntlm` or `basic` | +| `winrm_connect_timeout_seconds` | `WINRM_CONNECT_TIMEOUT_SECONDS` | `15` | `int` | WinRM connection timeout | +| `winrm_operation_timeout_seconds` | `WINRM_OPERATION_TIMEOUT_SECONDS` | `20` | `int` | WinRM operation timeout | +| `winrm_max_output_bytes` | `WINRM_MAX_OUTPUT_BYTES` | `51200` | `int` | Max bytes per output object | + +**SSH** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `ssh_known_hosts` | `SSH_KNOWN_HOSTS` | `~/.ssh/known_hosts` | `str` | Path or `"disable"` | +| `ssh_connect_timeout_seconds` | `SSH_CONNECT_TIMEOUT_SECONDS` | `10` | `int` | SSH connection timeout | +| `ssh_max_output_bytes` | `SSH_MAX_OUTPUT_BYTES` | `51200` | `int` | Max bytes per stdout/stderr | + +**Database** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `db_connect_timeout_seconds` | `DB_CONNECT_TIMEOUT_SECONDS` | `10` | `int` | DB connection timeout | +| `db_query_timeout_seconds` | `DB_QUERY_TIMEOUT_SECONDS` | `30` | `int` | Query execution timeout | +| `db_max_rows` | `DB_MAX_ROWS` | `1000` | `int` | Row result cap | +| `db_max_cell_bytes` | `DB_MAX_CELL_BYTES` | `1024` | `int` | Per-cell truncation threshold | + +**Logging** + +| Setting | Env var | Default | Type | Description | +|---------|---------|---------|------|-------------| +| `log_format` | `LOG_FORMAT` | `json` | `"json"\|"console"` | Output format | +| `log_level` | `LOG_LEVEL` | `INFO` | `"DEBUG"\|"INFO"\|..` | Minimum log level | + +#### Validators + +- `_parse_and_validate_api_keys` (model validator): validates that `MCP_API_KEYS` is non-empty and not equal to the default `"changeme"` — service refuses to start if either condition is violated. Raises `ValidationError` at import time (fail-fast). +- `mcp_api_keys` (property): splits `mcp_api_keys_raw` on commas, strips whitespace, returns `frozenset[str]`. Implemented as a `@property` (not a pydantic field) to avoid a name collision with the pydantic-settings env-var auto-mapping. +- `cyberark_ssl_verify`: maps `"false"` → `False`, `"true"` or `""` → `True`, anything else → path string +- `cyberark_cert_pfx_path`: empty string → `None` +- `_validate_pfx` (model validator): if PFX path is set, the file must exist and the password must be non-empty + +--- + +### 2.2 secret_store.py + +**Class:** `SecretStore` +**Singleton:** `secret_store = SecretStore()` + +#### Internal data structure + +```python +@dataclass(slots=True) +class _Entry: + handle_id: str # 32-char hex, the key in _store + username: str # plaintext (used as SSH/DB username) + password: SecretStr # pydantic SecretStr — prevents accidental str() exposure + created_at: float # time.monotonic() at creation + resolved: bool = False # set to True on first resolve + + def is_expired(self, ttl: int) -> bool: + return (time.monotonic() - self.created_at) > ttl +``` + +```python +class SecretStore: + _store: dict[str, _Entry] # handle_id → entry + _lock: asyncio.Lock # all mutations are locked +``` + +#### Methods + +**`async store(username, password) → str`** +1. Generate `handle_id = secrets.token_hex(16)` (32 hex chars, cryptographically random) +2. Create `_Entry(handle_id, username, SecretStr(password))` +3. Acquire lock, insert into `_store` +4. Return `"secret://" + handle_id` + +**`async resolve(handle, resolved_by="unknown") → (str, str)`** +1. Parse handle → extract `handle_id` (raises `ValueError` if prefix wrong) +2. Acquire lock +3. Lookup entry — `KeyError` if not found +4. Check TTL — `KeyError("expired")` + delete if expired +5. Check `resolved` + `single_use` — `KeyError("already consumed")` if violated +6. Mark `entry.resolved = True`; delete if `single_use` +7. Release lock +8. Log `handle_resolved` audit event +9. Return `(entry.username, entry.password.get_secret_value())` + +> `get_secret_value()` is the **only** intentional unwrap point in the entire codebase. + +**`async revoke(handle) → bool`** +Explicit early revocation. Returns `True` if the handle existed. + +**`async purge_expired() → int`** +Scans all entries and deletes expired ones. Called by the background sweeper every 60 seconds. Returns count of deleted entries. + +#### Background sweeper + +```python +async def _sweeper(store, interval_seconds=60): + while True: + await asyncio.sleep(interval_seconds) + count = await store.purge_expired() +``` + +Started in `main.py` lifespan as an `asyncio.Task`. Cancelled on shutdown. + +--- + +### 2.3 auth.py + +**Class:** `ApiKeyMiddleware(BaseHTTPMiddleware)` + +``` +Request arrives + │ + ▼ +Does path start with "/mcp/"? + │ + NO ├──────────────────────────────► pass through (health check etc.) + │ + YES ▼ +Extract key from headers: + 1. X-API-Key: + 2. Authorization: Bearer + │ + ▼ +key in settings.mcp_api_keys ? + │ + NO ├──────────────────────────────► 401 JSON + log_auth_failure() + │ + YES ▼ + call_next(request) +``` + +Key validation uses `hmac.compare_digest` in a non-short-circuiting loop over all configured keys, providing timing-safe comparison that prevents an attacker from inferring key length or prefix from response time differences: + +```python +@staticmethod +def _is_valid_key(key: str) -> bool: + key_bytes = key.encode() + valid = False + for configured_key in settings.mcp_api_keys: + if hmac.compare_digest(key_bytes, configured_key.encode()): + valid = True # set flag, do NOT return early + return valid +``` + +The loop always iterates all keys (no `return True` inside the loop) so the response time does not leak how many keys are configured or how close a guess was. + +--- + +### 2.4 audit.py + +Wraps `structlog` with named functions so every audit event has a consistent schema. See [Section 11](#11-audit-event-catalog) for the full catalog. + +**Configuration:** `configure_logging()` must be called once at startup (called in lifespan and in the `run()` entry point). + +Processors pipeline: +``` +merge_contextvars → add_logger_name → add_log_level → TimeStamper(iso) +→ StackInfoRenderer → ProcessorFormatter.wrap_for_formatter +→ [JSONRenderer | ConsoleRenderer] +``` + +Third-party loggers suppressed to WARNING: `uvicorn.access`, `asyncssh`, `pypsrp`. + +--- + +### 2.5 main.py + +**Function:** `create_app() → FastAPI` + +Assembly sequence: +1. Create `FastAPI(lifespan=lifespan, docs_url=None, ...)` — docs disabled in production +2. Add `ApiKeyMiddleware` +3. Register `GET /health` route (no auth) +4. Import and mount four MCP servers: + - `cyberark_mcp` at `/mcp/cyberark` + - `ssh_mcp` at `/mcp/ssh` + - `powershell_mcp` at `/mcp/powershell` + - `database_mcp` at `/mcp/database` + +**Lifespan (async context manager):** + +``` +startup: + configure_logging() + await cyberark_client.start() ← creates httpx.AsyncClient + sweeper_task = await start_sweeper(secret_store) + +shutdown: + sweeper_task.cancel() + await sweeper_task ← wait for cancellation + await cyberark_client.stop() ← closes httpx.AsyncClient +``` + +**CLI entry point:** `mcp-privileged` → `mcp_privileged.main:run` + +```python +def run(): + configure_logging() + app = create_app() + uvicorn.run(app, host=settings.mcp_host, port=settings.mcp_port, + log_config=None, access_log=False) +``` + +--- + +## 3. CyberArk MCP + +### 3.1 client.py — CyberArkCCPClient + +**Singleton:** `cyberark_client = CyberArkCCPClient()` + +#### Lifecycle + +```python +await cyberark_client.start() # creates httpx.AsyncClient +await cyberark_client.stop() # closes httpx.AsyncClient +``` + +The `httpx.AsyncClient` is created once and reused for connection pooling. Timeouts: connect=5s, read=15s, write=5s, pool=5s. + +#### `get_credential(app_id, safe, object_name) → Credential` + +``` +GET {CYBERARK_CCP_URL}?AppID={app_id}&Safe={safe}&Object={object_name} +``` + +Response parsing: +- HTTP 200 → `Credential` dataclass from JSON body +- HTTP 4xx/5xx → parse `ErrorCode`/`ErrorMsg` → raise `CyberArkError` +- Non-JSON body → raise `CyberArkError(status_code=...)` +- `httpx.ConnectError` → `CyberArkError("Cannot reach CCP")` +- `httpx.TimeoutException` → `CyberArkError("CCP request timed out")` + +#### SSL modes + +| Condition | `_build_ssl_context()` returns | +|-----------|-------------------------------| +| `cyberark_cert_pfx_path is None` | `settings.cyberark_ssl_verify` (bool or path) | +| PFX path set | `ssl.SSLContext` with client cert loaded | + +For mTLS, the PFX is parsed with `cryptography`, cert+key are written to a `tempfile.mkstemp(suffix=".pem")` with `chmod 600`, loaded into the SSLContext, then the temp file is immediately deleted with `os.unlink()`. + +#### Error codes + +| Code | Meaning | +|------|---------| +| `APPAP004E` | AppID not found or not permitted | +| `APPAP006E` | Authentication failure (IP allowlist / AppID mismatch) | +| `APPAP007E` | Credential object not found in safe | +| `APPAP008E` | No password found for object | +| `APPAP009E` | Dual control pending approval | +| `APPAP010E` | Dual control approval timed out | +| `ITATS023E` | Object not found | +| `ITATS012E` | Safe not found | + +### 3.2 server.py — CyberArk MCP + +**Tools:** `get_credential`, `list_safes` + +#### `get_credential(safe, object_name, ctx, app_id="")` + +``` +1. Resolve effective AppID (param or settings.cyberark_app_id) +2. ctx.info(...) +3. cyberark_client.get_credential(...) + → on CyberArkError: ctx.error(...); raise +4. secret_store.store(credential.username, credential.password) + → returns handle +5. log_credential_fetched(app_id, safe, object_name, handle_id, ttl, client_ip) +6. ctx.info("Credential retrieved. Handle issued...") +7. Return formatted string: + "Credential retrieved successfully.\n + Handle: secret://...\n + Username: ...\n + Address: ...\n + Platform: ...\n + TTL: 300 seconds\n + Use this handle with ssh_execute, ps_execute, or db_connect." +``` + +The return value is carefully crafted — it contains the handle (needed by Claude for the next step) plus metadata (username, address) to help Claude route the next call correctly, but **never** the password. + +#### `list_safes(ctx, app_id="")` + +Calls `cyberark_client.list_safes(app_id)`. Currently raises `NotImplementedError` (CCP has no native list-safes endpoint). The tool catches this and returns an informational message instead of raising. + +--- + +## 4. SSH MCP + +### 4.1 server.py + +**Tool:** `ssh_execute` + +#### Execution sequence + +``` +ssh_execute(host, command, secret_handle, ctx, port=22, username_override="", timeout_seconds=30) +│ +├── secret_store.resolve(secret_handle, resolved_by="ssh") +│ KeyError → ctx.error(...); raise +│ +├── if username_override: username = username_override +│ +├── ctx.info("SSH connecting to ...") +│ +├── _resolve_known_hosts(settings.ssh_known_hosts) +│ "disable" → None (no host key check, logs warning) +│ else → expanded path string +│ +├── async with asyncssh.connect(host, port, username, password, +│ known_hosts, connect_timeout) as conn: +│ ├── result = await conn.run(command, timeout=timeout_seconds) +│ └── [exceptions caught — see error matrix] +│ +├── del password (in finally block) +│ +├── stdout = _truncate(result.stdout, ssh_max_output_bytes, "stdout") +├── stderr = _truncate(result.stderr, ssh_max_output_bytes, "stderr") +├── exit_code = result.exit_status ?? -1 +│ +├── log_ssh_executed(...) +├── ctx.info("SSH command completed ...") +│ +└── return _format_result(host, command, exit_code, stdout, stderr) +``` + +#### Output format + +``` +Host: linux01.internal +Command: df -h +Exit code: 0 + +--- stdout --- +Filesystem Size Used Avail Use% Mounted on +/dev/sda1 50G 10G 40G 20% / + +--- stderr --- +(only present if non-empty) +``` + +#### Known hosts handling + +| `ssh_known_hosts` value | Passed to asyncssh | Behaviour | +|------------------------|-------------------|-----------| +| `"disable"` | `None` | No host key verification (dev/lab only) | +| `"~/.ssh/known_hosts"` | `"/home/user/.ssh/known_hosts"` | Verify against file | +| `/etc/ssh/known_hosts` | `/etc/ssh/known_hosts` | Verify against file | + +--- + +## 5. PowerShell MCP + +### 5.1 server.py + +**Tool:** `ps_execute` + +#### Thread executor pattern + +pypsrp is synchronous. The blocking WinRM call is wrapped in `asyncio.get_running_loop().run_in_executor(None, ...)`: + +```python +loop = asyncio.get_running_loop() +output_lines, had_errors, error_records = await loop.run_in_executor( + None, + functools.partial(_run_ps_sync, host, port, username, password, script, use_ssl, timeout_seconds), +) +``` + +`None` uses the default `ThreadPoolExecutor`. The event loop remains responsive to other requests while WinRM is in progress. + +#### `_run_ps_sync()` (thread worker) + +```python +wsman = WSMan( + host, port=port, username=username, password=password, + ssl=use_ssl, + auth=settings.winrm_auth, # "ntlm" or "basic" + cert_validation=use_ssl, # only check cert when using HTTPS + connection_timeout=settings.winrm_connect_timeout_seconds, + operation_timeout=max( + timeout_seconds + 10, + settings.winrm_operation_timeout_seconds, + ), +) +with RunspacePool(wsman) as pool: + ps = PowerShell(pool) + ps.add_script(script) + raw_output = ps.invoke() + had_errors = ps.had_errors + error_records = [str(e) for e in ps.streams.error] +``` + +Each item in `raw_output` is converted via `str()` and truncated to `winrm_max_output_bytes`. + +#### Output format + +``` +Host: win01.internal +Script length: 43 chars +Had errors: False + +--- output --- +WIN-SERVER-01 +6.1.7601.65536 + +--- errors --- +(only present if had_errors or error_records is non-empty) +``` + +--- + +## 6. Database MCP + +### 6.1 server.py + +**Tool:** `db_query` + +#### Driver dispatch + +```python +async def _dispatch_query(db_type, host, port, database, username, password, query, timeout_seconds): + if db_type == "postgres": return await _query_postgres(...) + if db_type == "mysql": return await _query_mysql(...) + # mssql: run synchronous pyodbc in thread pool + return await loop.run_in_executor(None, partial(_query_mssql_sync, ...)) +``` + +#### PostgreSQL (`asyncpg`) + +```python +conn = await asyncpg.connect(host, port, user, password, database, timeout=connect_timeout) +rows = await conn.fetch(query, timeout=query_timeout) +columns = list(rows[0].keys()) +data = [list(row.values()) for row in rows] +await conn.close() +``` + +#### MySQL (`aiomysql`) + +```python +conn = await aiomysql.connect(host, port, user, password, db, connect_timeout) +async with conn.cursor() as cursor: + await asyncio.wait_for(cursor.execute(query), timeout=query_timeout) + columns = [col[0] for col in cursor.description] + rows = await cursor.fetchall() +conn.close() +``` + +#### SQL Server (`pyodbc` — sync) + +```python +conn_str = ( + "DRIVER={ODBC Driver 18 for SQL Server};" + f"SERVER={host},{port};DATABASE={database};UID={username};PWD={password};" + f"Connection Timeout={connect_timeout};" +) +with pyodbc.connect(conn_str, timeout=query_timeout) as conn: + cursor = conn.cursor() + cursor.execute(query) + columns = [col[0] for col in cursor.description] + rows = [list(row) for row in cursor.fetchall()] +``` + +If `pyodbc` is not importable (missing system ODBC driver), raises `RuntimeError` with installation instructions. + +#### Row and cell limits + +After query execution: +1. If `len(rows) > settings.db_max_rows`: truncate to `db_max_rows`, set `truncated=True` +2. For each cell in `_format_result`: `_cell_str()` truncates at `db_max_cell_bytes` UTF-8 bytes and appends `…` + +#### Output format + +``` +Host: pg.internal +Database: prod (postgres) +Query length: 38 chars +Rows returned: 3 +Elapsed: 12ms + + id | name | email +----|---------|---------------- + 1 | Alice | alice@corp.com + 2 | Bob | bob@corp.com + 3 | Charlie | charlie@corp.com +``` + +If rows are capped: `Rows returned: 1000 (capped — more rows exist)` + +--- + +## 7. MCP Tool API Reference + +All tools follow the JSON-RPC 2.0 envelope defined by the MCP protocol. Parameters below are the tool-level parameters (inside `arguments`). + +### `get_credential` + +**MCP path:** `POST /mcp/cyberark/...` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `safe` | `string` | Yes | — | CyberArk Safe name | +| `object_name` | `string` | Yes | — | Credential object name in the Safe | +| `app_id` | `string` | No | `CYBERARK_APP_ID` | Override the service AppID | + +**Returns:** Plain text with handle, username, address, platform, TTL. + +**Errors:** +- `CyberArkError` — CCP returned an error (APPAP00xE etc.) +- `RuntimeError` — CyberArk client not started + +--- + +### `list_safes` + +**MCP path:** `POST /mcp/cyberark/...` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `app_id` | `string` | No | `CYBERARK_APP_ID` | AppID to list safes for | + +**Returns:** Newline-separated list of Safe names, or informational message if not configured. + +--- + +### `ssh_execute` + +**MCP path:** `POST /mcp/ssh/...` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `host` | `string` | Yes | — | Hostname or IP | +| `command` | `string` | Yes | — | Shell command | +| `secret_handle` | `string` | Yes | — | Handle from `get_credential` | +| `port` | `integer` | No | `22` | SSH port | +| `username_override` | `string` | No | `""` | Override credential username | +| `timeout_seconds` | `integer` | No | `30` | Command timeout | + +**Returns:** Formatted text with host, command, exit code, stdout, stderr. + +**Errors:** +- `KeyError` — handle not found, expired, or already consumed +- `asyncssh.PermissionDenied` — authentication failure +- `asyncssh.DisconnectError` — SSH disconnection +- `asyncio.TimeoutError` — command timed out +- `OSError` — network error (connection refused, DNS failure) + +--- + +### `ps_execute` + +**MCP path:** `POST /mcp/powershell/...` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `host` | `string` | Yes | — | Hostname or IP | +| `script` | `string` | Yes | — | PowerShell script text | +| `secret_handle` | `string` | Yes | — | Handle from `get_credential` | +| `port` | `integer` | No | `5985` | WinRM port (5986 for HTTPS) | +| `use_ssl` | `boolean` | No | `false` | Use HTTPS for WinRM | +| `timeout_seconds` | `integer` | No | `60` | Script execution timeout | +| `username_override` | `string` | No | `""` | Override credential username | + +**Returns:** Formatted text with host, script length, had_errors, output, error records. + +**Errors:** +- `KeyError` — handle not found/expired/consumed +- Any exception from pypsrp (WinRM connection error, auth failure, etc.) + +--- + +### `db_query` + +**MCP path:** `POST /mcp/database/...` + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `host` | `string` | Yes | — | Database server hostname | +| `database` | `string` | Yes | — | Database/schema name | +| `query` | `string` | Yes | — | SQL query text | +| `secret_handle` | `string` | Yes | — | Handle from `get_credential` | +| `db_type` | `string` | No | `"postgres"` | `"postgres"`, `"mysql"`, `"mssql"` | +| `port` | `integer` | No | `0` | 0 = use default for db_type | +| `username_override` | `string` | No | `""` | Override credential username | +| `timeout_seconds` | `integer` | No | `30` | Query timeout | + +**Returns:** Text table with columns, rows, counts, elapsed time. + +**Errors:** +- `ValueError` — unsupported `db_type` +- `KeyError` — handle not found/expired/consumed +- `asyncpg.PostgresError` — PostgreSQL error +- `aiomysql.Error` — MySQL error +- `pyodbc.Error` — SQL Server error +- `RuntimeError` — pyodbc not installed + +--- + +## 8. Data Models + +### Credential (CyberArk CCP response) + +```python +@dataclass(frozen=True) +class Credential: + username: str # "svc_account" + password: str # raw password — stored in SecretStr immediately + address: str # "db.internal" — target host from CyberArk + safe: str # "PROD-DB" + folder: str # "Root" + object_name: str # "PROD-DB-svc_account" + platform_id: str # "Oracle", "UnixSSH", etc. + password_change_in_process: bool # True if CyberArk is rotating this credential +``` + +> `password_change_in_process=True` should trigger a warning — the credential may be mid-rotation. + +### SecretStore entry + +```python +@dataclass(slots=True) +class _Entry: + handle_id: str # 32 hex chars (key in _store dict) + username: str + password: SecretStr # pydantic SecretStr — str() returns "**********" + created_at: float # time.monotonic() + resolved: bool # True after first resolve +``` + +### Handle format + +``` +secret://a3f9c2e1b8d74f2c9e1a0b5d3c8f7e2a + └──────────────────────────────────┘ + 32-char lowercase hex = 128 bits of entropy + from secrets.token_hex(16) +``` + +--- + +## 9. Configuration Reference + +Full `.env.example`: + +```ini +# ── Service ─────────────────────────────────────────────────────────── +MCP_HOST=0.0.0.0 +MCP_PORT=8443 +MCP_API_KEYS=key-for-claude-desktop,key-for-vscode + +# ── Secret Handle Store ─────────────────────────────────────────────── +HANDLE_TTL_SECONDS=300 +HANDLE_SINGLE_USE=true + +# ── CyberArk CCP ───────────────────────────────────────────────────── +CYBERARK_CCP_URL=https://cyberark.internal/AIMWebService/api/Accounts +CYBERARK_APP_ID=MCP-Privileged-Service +CYBERARK_VERIFY_SSL=/etc/ssl/certs/ca-certificates.crt + +# ── CyberArk mTLS (leave empty for IP allowlist mode) ───────────────── +CYBERARK_CERT_PFX_PATH= +CYBERARK_CERT_PFX_PASSWORD= + +# ── PowerShell / WinRM ──────────────────────────────────────────────── +WINRM_AUTH=ntlm +WINRM_CONNECT_TIMEOUT_SECONDS=15 +WINRM_OPERATION_TIMEOUT_SECONDS=20 +WINRM_MAX_OUTPUT_BYTES=51200 + +# ── SSH ─────────────────────────────────────────────────────────────── +SSH_KNOWN_HOSTS=~/.ssh/known_hosts +SSH_CONNECT_TIMEOUT_SECONDS=10 +SSH_MAX_OUTPUT_BYTES=51200 + +# ── Database ────────────────────────────────────────────────────────── +DB_CONNECT_TIMEOUT_SECONDS=10 +DB_QUERY_TIMEOUT_SECONDS=30 +DB_MAX_ROWS=1000 +DB_MAX_CELL_BYTES=1024 + +# ── Logging ─────────────────────────────────────────────────────────── +LOG_FORMAT=json +LOG_LEVEL=INFO +``` + +--- + +## 10. Error Handling Matrix + +| Layer | Exception | Handling | What Claude sees | +|-------|-----------|----------|-----------------| +| Auth middleware | Bad/missing key | 401 JSON response | `{"detail": "Invalid or missing API key"}` | +| SecretStore | `KeyError` (unknown) | Caught in tool, `ctx.error`, re-raised | MCP error response | +| SecretStore | `KeyError` (expired) | Same | MCP error response | +| SecretStore | `KeyError` (consumed) | Same | MCP error response | +| CyberArk CCP | `CyberArkError` | Caught in tool, `ctx.error`, re-raised | MCP error response with error code | +| SSH | `asyncssh.PermissionDenied` | `ctx.error`, re-raised | MCP error response | +| SSH | `asyncssh.DisconnectError` | `ctx.error`, re-raised | MCP error response | +| SSH | `asyncio.TimeoutError` | `ctx.error`, re-raised | MCP error response | +| SSH | `OSError` | `ctx.error`, re-raised | MCP error response | +| SSH | Non-zero exit code | NOT raised — returned in result | Normal result with `Exit code: N` | +| WinRM | Any exception from pypsrp | `ctx.error`, re-raised | MCP error response | +| WinRM | Script errors (`had_errors=True`) | NOT raised — returned in result | Normal result with `Had errors: True` | +| Database | `ValueError` (bad db_type) | Raised before credential access | MCP error response | +| Database | Driver exceptions | `ctx.error`, re-raised | MCP error response | +| Database | Row cap exceeded | NOT raised — result truncated | Normal result with `(capped)` note | + +--- + +## 11. Audit Event Catalog + +All events are emitted via structlog at `INFO` or `WARNING` level to the `audit` logger. +The logger is named `"audit"` — log shippers can filter on this name. + +| Event | Level | When | Key fields | +|-------|-------|------|-----------| +| `credential_fetched` | INFO | CyberArk credential retrieved | `app_id, safe, object_name, handle_id, ttl_seconds, client_ip` | +| `handle_resolved` | INFO | Handle consumed by a tool | `handle_id, resolved_by, target_host, single_use_invalidated` | +| `handle_expired` | WARNING | Handle TTL exceeded or already consumed | `handle_id, reason` | +| `auth_failure` | WARNING | Invalid/missing API key | `client_ip, reason` | +| `cyberark_error` | ERROR | CCP returned error | `app_id, safe, object_name, status_code, error_code, message` | +| `ssh_executed` | INFO | SSH command completed | `handle_id, host, port, username, command, exit_code, elapsed_ms, client_ip` | +| `ps_executed` | INFO | PowerShell script completed | `handle_id, host, port, username, script_length, had_errors, elapsed_ms, client_ip` | +| `db_queried` | INFO | Database query completed | `handle_id, host, port, database, db_type, username, query_length, row_count, elapsed_ms, client_ip` | + +**Fields intentionally absent from all events:** +- `password` (never) +- `secret_handle` (never — only `handle_id` which is non-reversible) +- stdout / stderr output (may contain sensitive data) +- SQL query text (logged only as `query_length`) +- PowerShell script text (logged only as `script_length`) + +--- + +## 12. Test Strategy + +### Test layout + +``` +tests/ +├── conftest.py ← shared fixtures and mock helpers +├── test_auth.py ← API key middleware (FastAPI TestClient) +├── test_secret_store.py ← handle lifecycle (pure asyncio) +├── test_cyberark_client.py ← CCP HTTP client (httpx MockTransport) +├── test_ssh_server.py ← SSH tool (mock asyncssh.connect) +├── test_powershell_server.py ← PS tool (mock _run_ps_sync) +├── test_database_server.py ← DB tool (mock _dispatch_query) +└── test_integration.py ← end-to-end pipelines (all mocks combined) +``` + +### Test patterns + +| Pattern | Used for | Why | +|---------|----------|-----| +| `httpx.MockTransport` | CyberArk client | Tests full HTTP response parsing without real CyberArk | +| `unittest.mock.patch` on transport layer | SSH, PowerShell, DB tools | Isolates MCP tool logic from network I/O | +| Real `secret_store` | All tool tests | Tests handle lifecycle end-to-end | +| `MagicMock` for `Context` | All tool tests | Tests `ctx.info` / `ctx.error` calls without MCP framework | +| `patch.object(settings, ...)` | Settings-sensitive tests | Overrides config for a test without process restart | + +### Coverage targets + +- Foundation modules: 100% +- CyberArk client: 100% (all HTTP response paths) +- MCP tools: ≥90% (happy path + all error paths) +- Integration flows: key pipelines (CyberArk→SSH, →PS, →DB) +- Known gap: real-system integration tests (require live CyberArk/WinRM/DB) + +### Running tests + +```bash +# All tests +python -m pytest tests/ -v + +# With coverage +python -m pytest tests/ --cov=src/mcp_privileged --cov-report=term-missing + +# Single module +python -m pytest tests/test_integration.py -v +``` diff --git a/docs/MANUAL.docx b/docs/MANUAL.docx new file mode 100644 index 0000000000000000000000000000000000000000..19a0268ff1feb51d837cb8bd6599cd7d38421062 GIT binary patch literal 50019 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 literal 0 HcmV?d00001 diff --git a/docs/MANUAL.md b/docs/MANUAL.md new file mode 100644 index 0000000..7d9e578 --- /dev/null +++ b/docs/MANUAL.md @@ -0,0 +1,966 @@ +# Operations Manual +# MCP Privileged Access Service + +**Version:** 1.0 +**Date:** 2026-03-28 +**Audience:** System administrators, security engineers, DevOps teams + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [CyberArk Prerequisites](#2-cyberark-prerequisites) +3. [Installation — Bare Metal / VM](#3-installation--bare-metal--vm) +4. [Installation — Docker](#4-installation--docker) +5. [Configuration Walkthrough](#5-configuration-walkthrough) +6. [SSH Host Key Setup](#6-ssh-host-key-setup) +7. [Windows WinRM Setup](#7-windows-winrm-setup) +8. [SQL Server ODBC Driver Setup](#8-sql-server-odbc-driver-setup) +9. [Claude Code Integration](#9-claude-code-integration) +10. [Usage Examples](#10-usage-examples) +11. [Monitoring & Log Events](#11-monitoring--log-events) +12. [Troubleshooting Guide](#13-troubleshooting-guide) +13. [Security Hardening Checklist](#14-security-hardening-checklist) +14. [Backup & Recovery](#15-backup--recovery) +15. [Upgrade Procedure](#16-upgrade-procedure) + +--- + +## 1. Prerequisites + +### System requirements + +| Component | Minimum | Recommended | +|-----------|---------|-------------| +| OS | Ubuntu 22.04 / RHEL 9 | Ubuntu 22.04 LTS | +| CPU | 1 vCPU | 2 vCPU | +| RAM | 512 MB | 1 GB | +| Disk | 2 GB | 5 GB (for logs) | +| Python | 3.11 | 3.11 | +| Network | See firewall rules below | — | + +### Network access required (outbound from service host) + +| Destination | Port | Protocol | Purpose | +|-------------|------|----------|---------| +| CyberArk CCP | 443 | HTTPS | Credential retrieval | +| Linux target hosts | 22 | SSH | `ssh_execute` tool | +| Windows target hosts | 5985 or 5986 | HTTP/HTTPS | `ps_execute` tool (WinRM) | +| PostgreSQL servers | 5432 | TCP | `db_query` (postgres) | +| MySQL servers | 3306 | TCP | `db_query` (mysql) | +| SQL Server | 1433 | TCP | `db_query` (mssql) | + +### Network access required (inbound to service host) + +| Source | Port | Protocol | Purpose | +|--------|------|----------|---------| +| Claude Code clients | 443 | HTTPS | MCP tool calls | +| Load balancer / monitoring | 8443 | HTTP | Health check (if no TLS termination) | + +--- + +## 2. CyberArk Prerequisites + +Before deploying the service, complete the following in CyberArk. + +### 2.1 Create an Application ID + +1. In PVWA, navigate to **Applications** → **Add Application** +2. Set the application name to `MCP-Privileged-Service` (or your chosen value) +3. Under **Authentication**, add the service host's IP address to the **Allowed Machines** list +4. Save + +### 2.2 Grant access to Safes + +For each Safe containing credentials the service needs to retrieve: +1. Navigate to the Safe → **Members** → **Add Member** +2. Add `MCP-Privileged-Service` (the Application ID) +3. Grant permissions: **Retrieve accounts** (minimum) +4. Do **NOT** grant: Add, Update, Delete, Manage — principle of least privilege + +### 2.3 Verify CCP is reachable + +From the service host: +```bash +curl -k "https://cyberark.internal/AIMWebService/api/Accounts?AppID=MCP-Privileged-Service&Safe=TEST&Object=TEST-obj" +``` + +Expected responses: +- HTTP 200 — credential returned (safe and object exist) +- HTTP 404 `APPAP007E` — AppID valid but object not found (CCP is reachable and trusted) +- HTTP 403 `APPAP006E` — IP not in allowlist (add the service host IP to CyberArk) +- Connection refused — CCP URL is wrong or firewall is blocking + +### 2.4 (Future) mTLS — Export client certificate + +1. In CyberArk, generate or import a client certificate for the AppID +2. Export as PFX with a strong password +3. Copy the PFX file to the service host at a path like `/app/certs/mcp.pfx` +4. Set `chmod 400 /app/certs/mcp.pfx` +5. Set `CYBERARK_CERT_PFX_PATH=/app/certs/mcp.pfx` and `CYBERARK_CERT_PFX_PASSWORD=` in `.env` + +--- + +## 3. Installation — Bare Metal / VM + +### 3.1 System packages + +```bash +sudo apt-get update +sudo apt-get install -y python3.11 python3.11-venv python3.11-dev \ + unixodbc unixodbc-dev ca-certificates +``` + +For SQL Server support (optional): +```bash +# Add Microsoft repository +curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - +curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list \ + | sudo tee /etc/apt/sources.list.d/mssql-release.list +sudo apt-get update +sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 +``` + +### 3.2 Create service user + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin mcpuser +sudo mkdir -p /opt/mcp-privileged /opt/mcp-privileged/certs +sudo chown mcpuser:mcpuser /opt/mcp-privileged +``` + +### 3.3 Install the package + +```bash +cd /opt/mcp-privileged + +# Create and activate virtualenv +python3.11 -m venv .venv +source .venv/bin/activate + +# Clone or copy source +# (assuming source is in /tmp/MCP_CyberArk) +pip install /tmp/MCP_CyberArk + +# Verify +mcp-privileged --help +``` + +### 3.4 Configure + +```bash +cp /tmp/MCP_CyberArk/.env.example /opt/mcp-privileged/.env +chmod 600 /opt/mcp-privileged/.env +nano /opt/mcp-privileged/.env +# Edit values — see Section 5 +``` + +### 3.5 Configure SSH known_hosts + +```bash +# Pre-populate known_hosts for all SSH target hosts: +sudo -u mcpuser ssh-keyscan linux01.internal linux02.internal >> \ + /home/mcpuser/.ssh/known_hosts 2>/dev/null +# Or set SSH_KNOWN_HOSTS=/etc/ssh/known_hosts and populate there +``` + +### 3.6 Create systemd service + +```bash +sudo tee /etc/systemd/system/mcp-privileged.service > /dev/null <<'EOF' +[Unit] +Description=MCP Privileged Access Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=mcpuser +Group=mcpuser +WorkingDirectory=/opt/mcp-privileged +EnvironmentFile=/opt/mcp-privileged/.env +ExecStart=/opt/mcp-privileged/.venv/bin/mcp-privileged +Restart=on-failure +RestartSec=5s + +# Security hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=/opt/mcp-privileged +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable mcp-privileged +sudo systemctl start mcp-privileged +sudo systemctl status mcp-privileged +``` + +### 3.7 Reverse proxy (nginx) + +```nginx +# /etc/nginx/sites-available/mcp-privileged +server { + listen 443 ssl; + server_name mcp.yourcompany.internal; + + ssl_certificate /etc/ssl/certs/mcp.crt; + ssl_certificate_key /etc/ssl/private/mcp.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Restrict to Claude Code client IPs (replace with real IPs) + allow 10.0.0.0/24; + deny all; + + location / { + proxy_pass http://127.0.0.1:8443; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $host; + proxy_read_timeout 120s; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/mcp-privileged /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## 4. Installation — Docker + +### 4.1 Build the image + +```bash +cd /path/to/MCP_CyberArk +docker build -t mcp-privileged:1.0 . +``` + +### 4.2 Create .env file + +```bash +cp .env.example .env +chmod 600 .env +# Edit .env with your values +``` + +### 4.3 Run with Docker Compose + +```bash +# Service only +docker compose up -d mcp-privileged + +# Service + test databases (for integration testing) +docker compose --profile db up -d +``` + +### 4.4 Run with Docker (direct) + +```bash +docker run -d \ + --name mcp-privileged \ + --restart unless-stopped \ + -p 8443:8443 \ + --env-file .env \ + -v "$(pwd)/certs:/app/certs:ro" \ + mcp-privileged:1.0 +``` + +### 4.5 View logs + +```bash +docker logs -f mcp-privileged +``` + +--- + +## 5. Configuration Walkthrough + +Copy `.env.example` to `.env` and set each value: + +### Mandatory values + +```ini +# API keys — comma-separated, no spaces around commas +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +MCP_API_KEYS=abc123def456...,xyz789uvw012... + +# CyberArk CCP URL — the full REST endpoint +CYBERARK_CCP_URL=https://cyberark.yourcompany.internal/AIMWebService/api/Accounts + +# AppID registered in CyberArk (must match exactly — case-sensitive) +CYBERARK_APP_ID=MCP-Privileged-Service +``` + +### TLS verification + +```ini +# Option 1: Use system CAs (default — works if CyberArk cert is signed by a trusted CA) +CYBERARK_VERIFY_SSL=true + +# Option 2: Custom CA bundle (common for internal PKI) +CYBERARK_VERIFY_SSL=/etc/ssl/certs/internal-ca-bundle.crt + +# Option 3: Disable (NEVER in production — dev/lab only) +CYBERARK_VERIFY_SSL=false +``` + +### Handle security + +```ini +# How long a handle stays valid (seconds). Shorter = more secure. +# Operations that take < 30s: keep at 120-300s +# Long-running database imports: consider up to 600s +HANDLE_TTL_SECONDS=300 + +# Single-use enforces that each get_credential call is for one operation only. +# Set to false only if Claude needs the same credential for multiple parallel calls. +HANDLE_SINGLE_USE=true +``` + +### WinRM authentication + +```ini +# ntlm — works for domain accounts, most common +# basic — works for local accounts but requires HTTPS (use_ssl=true in the tool call) +WINRM_AUTH=ntlm +``` + +### SSH known hosts + +```ini +# Use the service user's known_hosts file (default) +SSH_KNOWN_HOSTS=~/.ssh/known_hosts + +# Use a shared known_hosts for the whole service +SSH_KNOWN_HOSTS=/etc/mcp/ssh_known_hosts + +# Disable host key checking (dev/lab ONLY — logs a warning on every connection) +SSH_KNOWN_HOSTS=disable +``` + +### Logging + +```ini +# Production: use json for log shipping to SIEM +LOG_FORMAT=json +LOG_LEVEL=INFO + +# Development: use console for human-readable output +LOG_FORMAT=console +LOG_LEVEL=DEBUG +``` + +--- + +## 6. SSH Host Key Setup + +The service verifies SSH host keys against a `known_hosts` file. New hosts must be added before Claude can connect. + +### Add a single host + +```bash +# As the mcpuser (or root, then chown) +ssh-keyscan -H linux01.internal >> ~/.ssh/known_hosts +``` + +### Add multiple hosts from a list + +```bash +cat hosts.txt | xargs ssh-keyscan -H >> ~/.ssh/known_hosts +``` + +Where `hosts.txt` contains one hostname per line. + +### Using a shared known_hosts file + +```bash +# Create shared file +sudo mkdir -p /etc/mcp +sudo ssh-keyscan -H linux01.internal linux02.internal db01.internal \ + > /etc/mcp/ssh_known_hosts +sudo chown mcpuser:mcpuser /etc/mcp/ssh_known_hosts +sudo chmod 440 /etc/mcp/ssh_known_hosts +``` + +Then set `SSH_KNOWN_HOSTS=/etc/mcp/ssh_known_hosts` in `.env`. + +### Verify a host key + +```bash +ssh-keygen -F linux01.internal -f ~/.ssh/known_hosts +``` + +--- + +## 7. Windows WinRM Setup + +### 7.1 Enable WinRM on Windows hosts + +Run on each Windows target host (as Administrator): + +```powershell +# Enable WinRM with default settings (HTTP, port 5985) +Enable-PSRemoting -Force + +# Allow connections from the MCP service host IP +Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true +winrm set winrm/config/client/auth '@{Basic="true"}' + +# Allow specific IP in firewall (replace 10.0.0.5 with your service host IP) +New-NetFirewallRule -Name "WinRM-MCP" -DisplayName "WinRM for MCP Service" ` + -Protocol TCP -LocalPort 5985 ` + -RemoteAddress 10.0.0.5 -Action Allow +``` + +### 7.2 HTTPS WinRM (recommended for production) + +```powershell +# On the Windows host — create HTTPS listener with a certificate +# (assumes cert is in the Local Machine store) +$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*win01*" } +New-WSManInstance winrm/config/Listener ` + -SelectorSet @{Transport="HTTPS"; Address="*"} ` + -ValueSet @{CertificateThumbprint=$cert.Thumbprint} + +# Open HTTPS WinRM port in firewall +New-NetFirewallRule -Name "WinRM-HTTPS-MCP" ` + -Protocol TCP -LocalPort 5986 ` + -RemoteAddress 10.0.0.5 -Action Allow +``` + +Then use `port=5986` and `use_ssl=true` in `ps_execute` tool calls. + +### 7.3 Test WinRM from the service host + +```bash +# Test HTTP WinRM connectivity (requires Python + pypsrp) +python3 -c " +from pypsrp.wsman import WSMan +from pypsrp.powershell import PowerShell, RunspacePool +wsman = WSMan('win01.internal', port=5985, username='domain\\\\svc_user', + password='P@ssword', ssl=False, auth='ntlm') +with RunspacePool(wsman) as pool: + ps = PowerShell(pool) + ps.add_script('hostname') + out = ps.invoke() + print(out) +" +``` + +--- + +## 8. SQL Server ODBC Driver Setup + +Required for `db_query` with `db_type=mssql`. + +### Ubuntu 22.04 + +```bash +curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - +curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list \ + | sudo tee /etc/apt/sources.list.d/mssql-release.list +sudo apt-get update +sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev +``` + +### Verify ODBC driver + +```bash +odbcinst -q -d -n "ODBC Driver 18 for SQL Server" +# Should print the driver configuration +``` + +### Test SQL Server connectivity + +```bash +python3 -c " +import pyodbc +conn = pyodbc.connect('DRIVER={ODBC Driver 18 for SQL Server};' + 'SERVER=sql.internal,1433;DATABASE=master;' + 'UID=sa;PWD=P@ssword;') +cur = conn.cursor() +cur.execute('SELECT @@VERSION') +print(cur.fetchone()[0]) +" +``` + +--- + +## 9. Claude Code Integration + +### 9.1 Configure MCP servers in Claude Code + +Edit your Claude Code settings (usually `~/.claude/settings.json` or via `claude code config`): + +```json +{ + "mcpServers": { + "cyberark": { + "type": "http", + "url": "https://mcp.yourcompany.internal/mcp/cyberark", + "headers": { + "X-API-Key": "your-api-key-here" + } + }, + "ssh": { + "type": "http", + "url": "https://mcp.yourcompany.internal/mcp/ssh", + "headers": { + "X-API-Key": "your-api-key-here" + } + }, + "powershell": { + "type": "http", + "url": "https://mcp.yourcompany.internal/mcp/powershell", + "headers": { + "X-API-Key": "your-api-key-here" + } + }, + "database": { + "type": "http", + "url": "https://mcp.yourcompany.internal/mcp/database", + "headers": { + "X-API-Key": "your-api-key-here" + } + } + } +} +``` + +### 9.2 Verify connectivity + +In the Claude Code chat: +``` +Check if the MCP servers are connected +``` + +Claude should report all four MCP servers (cyberark, ssh, powershell, database) as available tools. + +### 9.3 Test with a simple operation + +``` +Using the PROD-LINUX safe, get the credential for svc_root on linux01.internal, +then run the command "whoami && uptime" on that host. +``` + +Claude should: +1. Call `get_credential(safe="PROD-LINUX", object_name="svc_root")` +2. Receive a handle +3. Call `ssh_execute(host="linux01.internal", command="whoami && uptime", secret_handle="secret://...")` +4. Return the output + +--- + +## 10. Usage Examples + +### Example 1: Check disk space on a Linux server + +**User prompt to Claude:** +``` +Get the root credential from the PROD-LINUX safe (object name: linux-root), +then check disk usage on server01.internal. +``` + +**What Claude does:** +1. `get_credential(safe="PROD-LINUX", object_name="linux-root")` + → Returns: `Handle: secret://abc123... Username: root Address: server01.internal` + +2. `ssh_execute(host="server01.internal", command="df -h", secret_handle="secret://abc123...")` + → Returns: + ``` + Host: server01.internal + Command: df -h + Exit code: 0 + + --- stdout --- + Filesystem Size Used Avail Use% Mounted on + /dev/sda1 50G 12G 38G 24% / + /dev/sdb1 200G 80G 120G 40% /data + ``` + +--- + +### Example 2: Run a PowerShell script on Windows + +**User prompt to Claude:** +``` +Get the domain admin credential from WIN-SAFE (object: domain-admin), +then list all running services on win-server01.internal that are stopped. +``` + +**What Claude does:** +1. `get_credential(safe="WIN-SAFE", object_name="domain-admin")` + +2. `ps_execute(host="win-server01.internal", script="Get-Service | Where-Object {$_.Status -eq 'Stopped'} | Select-Object Name, DisplayName", secret_handle="secret://...")` + → Returns: + ``` + Host: win-server01.internal + Script length: 89 chars + Had errors: False + + --- output --- + Name DisplayName + ---- ----------- + wuauserv Windows Update + XblGameSave Xbox Game Bar Saving Service + ``` + +--- + +### Example 3: Query a database + +**User prompt to Claude:** +``` +Get the db_reader credential from DB-SAFE (object: pg-reader), +then count the orders placed in the last 24 hours in the prod PostgreSQL database +on pg.internal, database name: orders. +``` + +**What Claude does:** +1. `get_credential(safe="DB-SAFE", object_name="pg-reader")` + +2. `db_query(host="pg.internal", database="orders", db_type="postgres", secret_handle="secret://...", query="SELECT COUNT(*) as orders_24h FROM orders WHERE created_at > NOW() - INTERVAL '24 hours'")` + → Returns: + ``` + Host: pg.internal + Database: orders (postgres) + Query length: 84 chars + Rows returned: 1 + Elapsed: 8ms + + orders_24h + ---------- + 1247 + ``` + +--- + +### Example 4: Multi-step workflow + +**User prompt to Claude:** +``` +I need to patch the Apache web servers in the PROD-LINUX safe. +For each of web01, web02, and web03: +1. Get the svc_admin credential +2. Run "sudo apt-get install --only-upgrade apache2 -y" on each host +3. Then check "apache2 -v" to confirm the version +``` + +**Note:** Because `HANDLE_SINGLE_USE=true`, Claude must call `get_credential` once per server (the handle is consumed by the first `ssh_execute`). + +--- + +## 11. Monitoring & Log Events + +### Log format (JSON) + +```json +{ + "event": "credential_fetched", + "logger": "audit", + "level": "info", + "timestamp": "2026-03-28T10:30:00.123Z", + "app_id": "MCP-Privileged-Service", + "safe": "PROD-LINUX", + "object_name": "linux-root", + "handle_id": "a3f9c2e1b8d74f2c", + "ttl_seconds": 300, + "client_ip": "10.0.0.50" +} +``` + +### Key events to alert on + +| Event | Condition | Suggested alert | +|-------|-----------|-----------------| +| `auth_failure` | `reason=invalid_or_missing_api_key` | Any single occurrence | +| `auth_failure` | Rate > 5/minute from same IP | Possible brute-force | +| `cyberark_error` | `error_code=APPAP006E` | CyberArk allowlist may be wrong | +| `cyberark_error` | Rate > 10/hour | Possible misconfiguration | +| `handle_expired` | `reason=already_consumed` + high rate | Handle replay attempt | +| `ssh_executed` | `exit_code != 0` | Command failure — review | +| `ps_executed` | `had_errors=true` | Script error — review | +| Health check | No response within 10s | Service down | + +### Log shipping + +The service writes JSON logs to stdout. Use your standard log shipper: + +**Filebeat:** +```yaml +- type: container + paths: + - /var/lib/docker/containers/*/*.log + processors: + - decode_json_fields: + fields: ["message"] + target: "" +``` + +**Splunk universal forwarder:** +Configure to tail the stdout log file or Docker container logs. + +**Grafana Loki + promtail:** +```yaml +scrape_configs: + - job_name: mcp-privileged + docker_sd_configs: + - host: unix:///var/run/docker.sock + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: mcp-privileged + action: keep +``` + +--- + +## 12. Troubleshooting Guide + +### Service fails to start + +**Symptom:** `systemctl status mcp-privileged` shows `failed` or immediate exit. + +**Check 1:** Configuration validation +```bash +cd /opt/mcp-privileged && source .venv/bin/activate +python3 -c "from mcp_privileged.config import settings; print('Config OK')" +``` +If this fails, the error message shows which setting is invalid. + +**Check 2:** PFX file (if mTLS is configured) +```bash +ls -la $CYBERARK_CERT_PFX_PATH +# Must exist and be readable by mcpuser +``` + +**Check 3:** Port in use +```bash +ss -tlnp | grep 8443 +``` + +--- + +### 401 Unauthorized from Claude Code + +**Cause:** API key mismatch between Claude Code settings and `MCP_API_KEYS`. + +**Verify:** +```bash +# Check what keys are configured (value is obfuscated in logs) +grep MCP_API_KEYS /opt/mcp-privileged/.env + +# Test with curl +curl -H "X-API-Key: your-key" https://mcp.yourcompany.internal/health +# Should return: {"status": "ok"} +``` + +--- + +### CyberArk error APPAP006E (authentication failure) + +**Cause:** The service host's IP is not in the CyberArk allowlist for the AppID. + +**Check:** What IP does CyberArk see? +```bash +# From the service host, check your outbound IP +curl https://api.ipify.org +# Or check your internal NAT gateway +``` + +**Fix:** In PVWA → Applications → `MCP-Privileged-Service` → Allowed Machines → Add the IP. + +--- + +### CyberArk error APPAP007E (object not found) + +**Cause:** The `safe` or `object_name` passed to `get_credential` does not exist in CyberArk. + +**Check:** +- Spelling and case of Safe name (CyberArk is case-sensitive) +- Object name — this is the **Account name** (Name field), not the address or username +- The AppID has Retrieve permission on the Safe + +--- + +### SSH connection fails: "Host key verification failed" + +**Cause:** The target host's SSH fingerprint is not in the known_hosts file. + +**Fix:** +```bash +ssh-keyscan -H linux01.internal >> ~/.ssh/known_hosts +# Or for the service user: +sudo -u mcpuser ssh-keyscan -H linux01.internal >> ~mcpuser/.ssh/known_hosts +``` + +**Quick diagnostic (dev only):** Temporarily set `SSH_KNOWN_HOSTS=disable` to confirm the issue is host key related, then fix properly. + +--- + +### SSH connection fails: "Permission denied" + +**Cause:** Wrong username/password, or password auth is disabled on the target host. + +**Check:** +1. Verify the credential in CyberArk PVWA (test retrieval) +2. Confirm the target host allows password authentication: `PasswordAuthentication yes` in `/etc/ssh/sshd_config` +3. Confirm the account is not locked: `passwd -S ` on the target + +--- + +### WinRM connection fails + +**Symptom:** `ps_execute` returns a WinRM connection error. + +**Check 1:** WinRM is running on the target +```powershell +# On the Windows host +Get-Service WinRM +winrm enumerate winrm/config/listener +``` + +**Check 2:** Firewall allows the connection +```powershell +# On the Windows host — test if port is open +Test-NetConnection -ComputerName localhost -Port 5985 +``` + +**Check 3:** Auth method matches +- NTLM: works for domain accounts and most setups +- Basic: requires `WINRM_AUTH=basic` in `.env` AND `use_ssl=true` in the tool call (Basic auth over HTTP is rejected by WinRM by default) + +--- + +### Database connection fails + +**PostgreSQL:** +```bash +# Test from service host +psql -h pg.internal -U db_user -d mydb -c "SELECT 1" +``` + +**MySQL:** +```bash +mysql -h mysql.internal -u db_user -p -e "SELECT 1" +``` + +**SQL Server (ODBC):** +```bash +isql -v "DRIVER={ODBC Driver 18 for SQL Server};SERVER=sql.internal,1433;DATABASE=master" \ + sa "P@ssword" +``` + +If `pyodbc` fails with `ImportError: libodbc.so.2: cannot open shared object file`: +```bash +sudo apt-get install -y unixodbc +``` + +--- + +### Handle expired / already consumed + +**Symptom:** Tool returns `KeyError: Handle expired` or `Handle already consumed`. + +**Causes:** +- The TTL elapsed between `get_credential` and the tool call → increase `HANDLE_TTL_SECONDS` +- `HANDLE_SINGLE_USE=true` and Claude tried to reuse the handle → normal behaviour; Claude should call `get_credential` again +- Clock skew on the service host (TTL uses `time.monotonic()`, so clock skew does not affect it) + +--- + +## 13. Security Hardening Checklist + +Use this checklist before production deployment. + +### Network +- [ ] Service host is in a restricted network segment (not accessible from general office network) +- [ ] Firewall rules allow only approved Claude Code client IPs to reach port 443 +- [ ] Service host can only reach: CyberArk CCP, target SSH hosts, WinRM hosts, DB servers — no internet +- [ ] Reverse proxy handles TLS termination with a valid internal CA certificate + +### Service configuration +- [ ] `MCP_API_KEYS` is set to strong random keys (minimum 32 chars each) +- [ ] Default key `changeme` is NOT present in `MCP_API_KEYS` +- [ ] `HANDLE_SINGLE_USE=true` (default) +- [ ] `HANDLE_TTL_SECONDS` ≤ 300 (5 minutes) +- [ ] `CYBERARK_VERIFY_SSL` is **not** set to `false` +- [ ] `SSH_KNOWN_HOSTS` is **not** set to `disable` +- [ ] `LOG_FORMAT=json` (for log shipping) +- [ ] `.env` file has `chmod 600` and is owned by the service user + +### CyberArk +- [ ] AppID has only Retrieve permission on Safes (no Add/Update/Delete) +- [ ] IP allowlist is restricted to the service host IP only +- [ ] A dedicated AppID is used for this service (not shared with other applications) + +### Docker +- [ ] Container runs as non-root (`USER mcpuser` in Dockerfile — already done) +- [ ] Secrets are passed via `--env-file`, not `-e PASSWORD=...` in docker run +- [ ] Docker socket is not mounted into the container +- [ ] Image is built from official Python base image (verified digest) + +### Operating system +- [ ] OS is patched and on a supported LTS release +- [ ] Service runs as a dedicated non-root user (`mcpuser`) +- [ ] systemd unit has `NoNewPrivileges=yes` and `ProtectSystem=strict` +- [ ] Log rotation is configured for stdout logs +- [ ] auditd or similar is monitoring privileged operations + +--- + +## 14. Backup & Recovery + +The service is **stateless**: no persistent data is stored on disk. + +- **Configuration:** The only file that needs backing up is `.env`. Store it in your secrets management system (HashiCorp Vault, AWS Secrets Manager, etc.), not in a generic file backup. +- **Certificates:** Back up PFX files and known_hosts files to your PKI or secrets vault. +- **Recovery:** To restore after a host failure, provision a new VM, install the package, and restore `.env` + certificates. All handles in RAM are lost (no active handles = fail-safe state; users must call `get_credential` again). +- **RTO:** < 5 minutes (container restart or new VM + `.env` restore). +- **RPO:** 0 (no data to lose — the service holds no persistent state). + +--- + +## 15. Upgrade Procedure + +### Minor upgrade (no config changes) + +```bash +# Docker +docker pull mcp-privileged:1.1 +docker compose up -d mcp-privileged + +# Bare metal +cd /opt/mcp-privileged && source .venv/bin/activate +pip install --upgrade /path/to/new/mcp_privileged-1.1.tar.gz +sudo systemctl restart mcp-privileged +``` + +Active handles are lost on restart (they expire within TTL anyway). Notify users if the restart window > 5 minutes. + +### Major upgrade (config changes) + +1. Read the release notes — check for new required env vars +2. Test in a staging environment first +3. Update `.env` with new required values +4. Follow the minor upgrade steps above +5. Monitor logs for errors in the first 10 minutes + +### Rollback + +```bash +# Docker — roll back to previous image tag +docker compose down +docker run --name mcp-privileged mcp-privileged:1.0 ... + +# Bare metal +pip install mcp_privileged==1.0 +sudo systemctl restart mcp-privileged +``` diff --git a/docs/md_to_docx.py b/docs/md_to_docx.py new file mode 100644 index 0000000..9c615e6 --- /dev/null +++ b/docs/md_to_docx.py @@ -0,0 +1,343 @@ +""" +Convert the three MCP documentation Markdown files to Word (.docx) format. + +Handles: + - Heading levels 1–4 + - Bold (**text**) and inline code (`text`) + - Fenced code blocks (``` ... ```) + - Tables (| col | col |) + - Unordered lists (- item, * item) + - Ordered lists (1. item) + - Horizontal rules (---) + - Blank lines → paragraph spacing + +Run: + python docs/md_to_docx.py +Produces: + docs/HLD.docx + docs/LLD.docx + docs/MANUAL.docx +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +from docx.shared import Inches, Pt, RGBColor + + +# ── Colour palette ──────────────────────────────────────────────────────────── +DARK_BLUE = RGBColor(0x1F, 0x49, 0x7D) # heading 1 +MID_BLUE = RGBColor(0x2E, 0x74, 0xB5) # heading 2 +STEEL_BLUE = RGBColor(0x1F, 0x78, 0xB4) # heading 3 +DARK_GREY = RGBColor(0x40, 0x40, 0x40) # body text +CODE_BG = RGBColor(0xF2, 0xF2, 0xF2) # code block shading +TABLE_HEAD = RGBColor(0x1F, 0x49, 0x7D) # table header background +TABLE_EVEN = RGBColor(0xEA, 0xF2, 0xFF) # alternating row colour + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _shade_cell(cell, colour: RGBColor) -> None: + """Apply a solid background fill to a table cell.""" + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), f"{colour[0]:02X}{colour[1]:02X}{colour[2]:02X}") + tcPr.append(shd) + + +def _set_cell_border(cell, **kwargs) -> None: + """Set borders on a table cell.""" + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + tcBorders = OxmlElement("w:tcBorders") + for side in ("top", "left", "bottom", "right", "insideH", "insideV"): + if side in kwargs: + border = OxmlElement(f"w:{side}") + for attr, val in kwargs[side].items(): + border.set(qn(f"w:{attr}"), val) + tcBorders.append(border) + tcPr.append(tcBorders) + + +def _apply_inline(run, text: str) -> None: + """Set run text, detecting and stripping bold/inline-code markers.""" + run.text = text + + +def _parse_inline(para, text: str) -> None: + """ + Parse a line of text for inline Markdown: + **bold** → bold run + `code` → monospace run + plain → normal run + Adds runs to the given paragraph. + """ + pattern = re.compile(r'(\*\*[^*]+\*\*|`[^`]+`)') + parts = pattern.split(text) + for part in parts: + if not part: + continue + if part.startswith("**") and part.endswith("**"): + run = para.add_run(part[2:-2]) + run.bold = True + elif part.startswith("`") and part.endswith("`"): + run = para.add_run(part[1:-1]) + run.font.name = "Courier New" + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(0xC0, 0x39, 0x2B) + else: + run = para.add_run(part) + + +def _add_heading(doc: Document, text: str, level: int) -> None: + """Add a styled heading, stripping any leading '#' symbols.""" + clean = re.sub(r"^#+\s*", "", text).strip() + # Remove anchor links like {#section-name} + clean = re.sub(r"\s*\{#[^}]+\}", "", clean) + para = doc.add_heading(clean, level=level) + run = para.runs[0] if para.runs else para.add_run(clean) + if level == 1: + run.font.color.rgb = DARK_BLUE + run.font.size = Pt(20) + elif level == 2: + run.font.color.rgb = MID_BLUE + run.font.size = Pt(15) + elif level == 3: + run.font.color.rgb = STEEL_BLUE + run.font.size = Pt(12) + else: + run.font.color.rgb = DARK_GREY + run.font.size = Pt(11) + run.bold = True + + +def _add_code_block(doc: Document, lines: list[str]) -> None: + """Add a shaded monospace code block.""" + para = doc.add_paragraph() + para.paragraph_format.left_indent = Inches(0.3) + para.paragraph_format.space_before = Pt(4) + para.paragraph_format.space_after = Pt(4) + # Add shading via XML + pPr = para._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), "F2F2F2") + pPr.append(shd) + + text = "\n".join(lines) + run = para.add_run(text) + run.font.name = "Courier New" + run.font.size = Pt(8.5) + run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x1A) + + +def _add_table(doc: Document, rows: list[list[str]]) -> None: + """Add a formatted table. First row is treated as the header.""" + if not rows: + return + col_count = max(len(r) for r in rows) + # Normalise row lengths + rows = [r + [""] * (col_count - len(r)) for r in rows] + + table = doc.add_table(rows=len(rows), cols=col_count) + table.style = "Table Grid" + + for row_idx, row_data in enumerate(rows): + row = table.rows[row_idx] + for col_idx, cell_text in enumerate(row_data): + cell = row.cells[col_idx] + clean = cell_text.strip().strip("`") + para = cell.paragraphs[0] + para.paragraph_format.space_before = Pt(2) + para.paragraph_format.space_after = Pt(2) + + if row_idx == 0: + # Header row + _shade_cell(cell, TABLE_HEAD) + run = para.add_run(clean) + run.bold = True + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + run.font.size = Pt(9) + else: + if row_idx % 2 == 0: + _shade_cell(cell, TABLE_EVEN) + _parse_inline(para, clean) + for run in para.runs: + run.font.size = Pt(9) + + doc.add_paragraph() # spacing after table + + +def _add_list_item(doc: Document, text: str, level: int, ordered: bool, + counter: int) -> None: + """Add a bullet or numbered list item.""" + style = "List Bullet" if not ordered else "List Number" + para = doc.add_paragraph(style=style) + if level > 0: + para.paragraph_format.left_indent = Inches(0.25 * (level + 1)) + _parse_inline(para, text) + for run in para.runs: + run.font.size = Pt(10) + + +def _parse_md_table(raw_rows: list[str]) -> list[list[str]]: + """Convert raw Markdown table lines to a list of cell lists.""" + result = [] + for line in raw_rows: + # Skip separator rows (---|---) + if re.match(r"^\s*\|?[\s\-:]+\|[\s\-:|]+\s*$", line): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if cells: + result.append(cells) + return result + + +# ── Main converter ──────────────────────────────────────────────────────────── + +def convert(md_path: Path, docx_path: Path) -> None: + doc = Document() + + # Page margins + for section in doc.sections: + section.top_margin = Inches(1.0) + section.bottom_margin = Inches(1.0) + section.left_margin = Inches(1.2) + section.right_margin = Inches(1.2) + + # Default body style + style = doc.styles["Normal"] + style.font.name = "Calibri" + style.font.size = Pt(10.5) + style.font.color.rgb = DARK_GREY + + lines = md_path.read_text(encoding="utf-8").splitlines() + + i = 0 + in_code_block = False + code_lines: list[str] = [] + table_rows: list[str] = [] + in_table = False + + while i < len(lines): + line = lines[i] + + # ── Fenced code block ────────────────────────────────────────────── + if line.strip().startswith("```"): + if not in_code_block: + in_code_block = True + code_lines = [] + else: + in_code_block = False + _add_code_block(doc, code_lines) + i += 1 + continue + + if in_code_block: + code_lines.append(line) + i += 1 + continue + + # ── Table detection ──────────────────────────────────────────────── + is_table_line = "|" in line and line.strip().startswith("|") + if is_table_line: + table_rows.append(line) + i += 1 + continue + elif table_rows: + parsed = _parse_md_table(table_rows) + if parsed: + _add_table(doc, parsed) + table_rows = [] + + # ── Headings ──────────────────────────────────────────────────────── + m = re.match(r"^(#{1,4})\s+(.+)$", line) + if m: + level = len(m.group(1)) + _add_heading(doc, m.group(2), level) + i += 1 + continue + + # ── Horizontal rule ───────────────────────────────────────────────── + if re.match(r"^[-*_]{3,}\s*$", line.strip()): + para = doc.add_paragraph() + pPr = para._p.get_or_add_pPr() + pBdr = OxmlElement("w:pBdr") + bottom = OxmlElement("w:bottom") + bottom.set(qn("w:val"), "single") + bottom.set(qn("w:sz"), "6") + bottom.set(qn("w:space"), "1") + bottom.set(qn("w:color"), "2E74B5") + pBdr.append(bottom) + pPr.append(pBdr) + i += 1 + continue + + # ── Unordered list ────────────────────────────────────────────────── + m = re.match(r"^(\s*)[-*]\s+(.+)$", line) + if m: + indent = len(m.group(1)) // 2 + _add_list_item(doc, m.group(2), indent, ordered=False, counter=0) + i += 1 + continue + + # ── Ordered list ──────────────────────────────────────────────────── + m = re.match(r"^(\s*)\d+\.\s+(.+)$", line) + if m: + indent = len(m.group(1)) // 2 + _add_list_item(doc, m.group(2), indent, ordered=True, counter=0) + i += 1 + continue + + # ── Blank line ────────────────────────────────────────────────────── + if not line.strip(): + i += 1 + continue + + # ── Plain paragraph ───────────────────────────────────────────────── + para = doc.add_paragraph() + para.paragraph_format.space_after = Pt(4) + _parse_inline(para, line) + for run in para.runs: + run.font.size = Pt(10.5) + i += 1 + + # Flush any remaining table + if table_rows: + parsed = _parse_md_table(table_rows) + if parsed: + _add_table(doc, parsed) + + doc.save(str(docx_path)) + print(f" Written: {docx_path} ({docx_path.stat().st_size // 1024} KB)") + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + docs_dir = Path(__file__).parent + + files = [ + ("HLD.md", "HLD.docx"), + ("LLD.md", "LLD.docx"), + ("MANUAL.md", "MANUAL.docx"), + ] + + print("Converting Markdown → Word (.docx) ...") + for md_name, docx_name in files: + md_path = docs_dir / md_name + docx_path = docs_dir / docx_name + print(f" Processing {md_name} ...") + convert(md_path, docx_path) + + print("Done.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..641b9a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mcp-privileged" +version = "0.1.0" +description = "Remote MCP service for privileged access via CyberArk CCP" +requires-python = ">=3.11" +dependencies = [ + "mcp[server]>=1.0", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "httpx>=0.27", + "cryptography>=42", + "asyncssh>=2.14", + "pypsrp>=0.9", + "asyncpg>=0.29", + "aiomysql>=0.2", + "pyodbc>=5.0", + "pydantic-settings>=2", + "structlog>=24", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "httpx>=0.27", # TestClient + "pytest-cov>=5", +] + +[project.scripts] +mcp-privileged = "mcp_privileged.main:run" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_privileged"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/mcp_privileged/__init__.py b/src/mcp_privileged/__init__.py new file mode 100644 index 0000000..05c4c3e --- /dev/null +++ b/src/mcp_privileged/__init__.py @@ -0,0 +1 @@ +# mcp_privileged diff --git a/src/mcp_privileged/audit.py b/src/mcp_privileged/audit.py new file mode 100644 index 0000000..171def0 --- /dev/null +++ b/src/mcp_privileged/audit.py @@ -0,0 +1,228 @@ +""" +Structured audit logger. + +Rules: + - Never log actual credential values. + - Every credential fetch and handle resolution is recorded. + - Output format matches `settings.log_format` (json | console). +""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + +import structlog + +from mcp_privileged.config import settings + +# ── stdlib logging → structlog bridge ──────────────────────────────────────── + +def _configure_stdlib_logging() -> None: + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=settings.log_level, + ) + # Suppress noisy third-party loggers + for noisy in ("uvicorn.access", "asyncssh", "pypsrp"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + +def configure_logging() -> None: + """Call once at service startup.""" + _configure_stdlib_logging() + + shared_processors: list[Any] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + if settings.log_format == "json": + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer(colors=True) + + structlog.configure( + processors=shared_processors + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + root_logger = logging.getLogger() + root_logger.handlers = [handler] + root_logger.setLevel(settings.log_level) + + +# ── Audit helpers ───────────────────────────────────────────────────────────── + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + return structlog.get_logger(name) + + +_audit = structlog.get_logger("audit") + + +def log_credential_fetched( + *, + app_id: str, + safe: str, + object_name: str, + handle_id: str, + ttl_seconds: int, + client_ip: str, +) -> None: + """Recorded when a credential is successfully retrieved from CyberArk CCP.""" + _audit.info( + "credential_fetched", + app_id=app_id, + safe=safe, + object_name=object_name, + handle_id=handle_id, + ttl_seconds=ttl_seconds, + client_ip=client_ip, + # password is intentionally absent + ) + + +def log_handle_resolved( + *, + handle_id: str, + resolved_by: str, # which MCP server resolved it (e.g. "ssh", "database") + target_host: str | None, + single_use_invalidated: bool, +) -> None: + """Recorded when a secret handle is resolved internally by another MCP.""" + _audit.info( + "handle_resolved", + handle_id=handle_id, + resolved_by=resolved_by, + target_host=target_host, + single_use_invalidated=single_use_invalidated, + ) + + +def log_handle_expired(*, handle_id: str, reason: str) -> None: + _audit.warning("handle_expired", handle_id=handle_id, reason=reason) + + +def log_auth_failure(*, client_ip: str, reason: str) -> None: + _audit.warning("auth_failure", client_ip=client_ip, reason=reason) + + +def log_ssh_executed( + *, + handle_id: str, + host: str, + port: int, + username: str, + command: str, + exit_code: int, + elapsed_ms: float, + client_ip: str, +) -> None: + """Recorded when an SSH command completes (success or non-zero exit).""" + _audit.info( + "ssh_executed", + handle_id=handle_id, + host=host, + port=port, + username=username, + command=command, + exit_code=exit_code, + elapsed_ms=round(elapsed_ms, 1), + client_ip=client_ip, + # stdout/stderr intentionally absent — may contain sensitive data + ) + + +def log_ps_executed( + *, + handle_id: str, + host: str, + port: int, + username: str, + script_length: int, # char count, not the script itself + had_errors: bool, + elapsed_ms: float, + client_ip: str, +) -> None: + """Recorded when a PowerShell/WinRM script completes.""" + _audit.info( + "ps_executed", + handle_id=handle_id, + host=host, + port=port, + username=username, + script_length=script_length, + had_errors=had_errors, + elapsed_ms=round(elapsed_ms, 1), + client_ip=client_ip, + # script body / output intentionally absent + ) + + +def log_db_queried( + *, + handle_id: str, + host: str, + port: int, + database: str, + db_type: str, + username: str, + query_length: int, # char count, not the query text + row_count: int, + elapsed_ms: float, + client_ip: str, +) -> None: + """Recorded when a database query completes.""" + _audit.info( + "db_queried", + handle_id=handle_id, + host=host, + port=port, + database=database, + db_type=db_type, + username=username, + query_length=query_length, + row_count=row_count, + elapsed_ms=round(elapsed_ms, 1), + client_ip=client_ip, + # query text / results intentionally absent + ) + + +def log_cyberark_error( + *, + app_id: str, + safe: str, + object_name: str, + status_code: int | None, + error_code: str | None, + message: str, +) -> None: + _audit.error( + "cyberark_error", + app_id=app_id, + safe=safe, + object_name=object_name, + status_code=status_code, + error_code=error_code, + message=message, + ) diff --git a/src/mcp_privileged/auth.py b/src/mcp_privileged/auth.py new file mode 100644 index 0000000..2ce082e --- /dev/null +++ b/src/mcp_privileged/auth.py @@ -0,0 +1,94 @@ +""" +API key authentication middleware for the remote MCP service. + +Every request to /mcp/* must carry a valid key in one of: + - Header: X-API-Key: + - Header: Authorization: Bearer + +Invalid or missing keys are rejected with 401 before any MCP logic runs. +Failed attempts are audit-logged with the client IP. +""" + +from __future__ import annotations + +import hmac + +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.types import ASGIApp + +from mcp_privileged.audit import log_auth_failure +from mcp_privileged.config import settings + + +class ApiKeyMiddleware(BaseHTTPMiddleware): + """ + Validates API keys on all routes under /mcp/. + Health-check and root routes are intentionally excluded. + """ + + PROTECTED_PREFIX = "/mcp/" + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if not request.url.path.startswith(self.PROTECTED_PREFIX): + return await call_next(request) + + key = self._extract_key(request) + if not self._is_valid_key(key): + client_ip = self._client_ip(request) + log_auth_failure( + client_ip=client_ip, + reason="invalid_or_missing_api_key", + ) + return JSONResponse( + status_code=401, + content={"detail": "Invalid or missing API key"}, + ) + + return await call_next(request) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + @staticmethod + def _is_valid_key(key: str) -> bool: + """ + Constant-time comparison against all configured API keys. + + hmac.compare_digest prevents timing attacks that could allow an attacker + to enumerate valid key prefixes by measuring response latency. + We never short-circuit — every configured key is always compared. + """ + key_bytes = key.encode() + valid = False + for configured_key in settings.mcp_api_keys: + if hmac.compare_digest(key_bytes, configured_key.encode()): + valid = True + return valid + + @staticmethod + def _extract_key(request: Request) -> str: + # Prefer explicit X-API-Key header + key = request.headers.get("X-API-Key", "") + if key: + return key + # Fall back to Bearer token + auth = request.headers.get("Authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:] + return "" + + @staticmethod + def _client_ip(request: Request) -> str: + # Respect X-Forwarded-For when behind a reverse proxy + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" diff --git a/src/mcp_privileged/config.py b/src/mcp_privileged/config.py new file mode 100644 index 0000000..f0a1df2 --- /dev/null +++ b/src/mcp_privileged/config.py @@ -0,0 +1,122 @@ +""" +Central configuration — loaded once at startup from environment / .env file. +All other modules import `settings` from here; nothing reads os.environ directly. +""" + +from __future__ import annotations + +from pathlib import Path + +from typing import Literal + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # ── Service ─────────────────────────────────────────────────────────────── + mcp_host: str = "0.0.0.0" + mcp_port: int = 8443 + + # Raw comma-separated string from env; access via the mcp_api_keys property below + mcp_api_keys_raw: str = Field(alias="MCP_API_KEYS", default="changeme") + + @model_validator(mode="after") + def _parse_and_validate_api_keys(self) -> "Settings": + keys = frozenset(k.strip() for k in self.mcp_api_keys_raw.split(",") if k.strip()) + if not keys: + raise ValueError("MCP_API_KEYS must contain at least one key") + if keys == {"changeme"}: + raise ValueError( + "MCP_API_KEYS is still set to the default 'changeme' — " + "set a strong random key before starting the service" + ) + return self + + @property + def mcp_api_keys(self) -> frozenset[str]: + """Parsed set of API keys — use this everywhere instead of mcp_api_keys_raw.""" + return frozenset(k.strip() for k in self.mcp_api_keys_raw.split(",") if k.strip()) + + # ── Secret Handle Store ─────────────────────────────────────────────────── + handle_ttl_seconds: int = Field(default=300, ge=30, le=3600) + handle_single_use: bool = True + + # ── CyberArk CCP ───────────────────────────────────────────────────────── + cyberark_ccp_url: str = "https://cyberark.internal/AIMWebService/api/Accounts" + cyberark_app_id: str = "MCP-Privileged-Service" + + # SSL verification: path to CA bundle, or the string "false" to disable + cyberark_verify_ssl: str = "/etc/ssl/certs/ca-certificates.crt" + + @property + def cyberark_ssl_verify(self) -> bool | str: + """ + Returns False to disable verification (dev only), + or a CA bundle path string, or True for default system CAs. + """ + v = self.cyberark_verify_ssl.strip() + if v.lower() == "false": + return False + if v.lower() in ("true", ""): + return True + return v # path to CA bundle + + # ── CyberArk mTLS (future) ──────────────────────────────────────────────── + cyberark_cert_pfx_path: Path | None = None + cyberark_cert_pfx_password: str | None = None + + @field_validator("cyberark_cert_pfx_path", mode="before") + @classmethod + def _empty_str_to_none(cls, v: str | None) -> Path | None: + if not v or str(v).strip() == "": + return None + return Path(v) + + @model_validator(mode="after") + def _validate_pfx(self) -> "Settings": + if self.cyberark_cert_pfx_path is not None: + if not self.cyberark_cert_pfx_path.exists(): + raise ValueError( + f"CYBERARK_CERT_PFX_PATH does not exist: {self.cyberark_cert_pfx_path}" + ) + if not self.cyberark_cert_pfx_password: + raise ValueError( + "CYBERARK_CERT_PFX_PASSWORD is required when a PFX path is set" + ) + return self + + # ── PowerShell / WinRM ──────────────────────────────────────────────────── + # auth: "ntlm" (default, domain accounts), "basic" (local accounts, needs HTTPS) + winrm_auth: Literal["ntlm", "basic"] = "ntlm" + winrm_connect_timeout_seconds: int = 15 + winrm_operation_timeout_seconds: int = 20 + winrm_max_output_bytes: int = 51_200 # 50 KB per output stream + + # ── SSH ─────────────────────────────────────────────────────────────────── + # "disable" skips host-key checking (dev/lab only). Any other value is + # treated as a path to a known_hosts file (~ is expanded at runtime). + ssh_known_hosts: str = "~/.ssh/known_hosts" + ssh_connect_timeout_seconds: int = 10 + ssh_max_output_bytes: int = 51_200 # 50 KB per stream (stdout / stderr) + + # ── Database ────────────────────────────────────────────────────────────── + db_connect_timeout_seconds: int = 10 + db_query_timeout_seconds: int = 30 + db_max_rows: int = 1_000 # cap fetched rows to protect LLM context + db_max_cell_bytes: int = 1_024 # truncate any single cell value beyond this + + # ── Logging ─────────────────────────────────────────────────────────────── + log_format: Literal["json", "console"] = "json" + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + + +# Single shared instance — import this everywhere +settings = Settings() diff --git a/src/mcp_privileged/cyberark/__init__.py b/src/mcp_privileged/cyberark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_privileged/cyberark/client.py b/src/mcp_privileged/cyberark/client.py new file mode 100644 index 0000000..d6c8fe1 --- /dev/null +++ b/src/mcp_privileged/cyberark/client.py @@ -0,0 +1,245 @@ +""" +CyberArk Central Credential Provider (CCP) REST client. + +Auth modes: + IP Allowlist (current): Plain HTTPS — CyberArk trusts the caller by source IP. + mTLS (future): Client certificate from a PFX file sent on every request. + +The client is instantiated once at startup and reused across requests via a +persistent httpx.AsyncClient (connection pooling). +""" + +from __future__ import annotations + +import os +import ssl +import tempfile +from dataclasses import dataclass + +import httpx +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) +from cryptography.hazmat.primitives.serialization.pkcs12 import load_pkcs12 + +from mcp_privileged.audit import get_logger, log_cyberark_error +from mcp_privileged.config import settings + +log = get_logger(__name__) + +# ── CyberArk error code → human-readable messages ──────────────────────────── +_CYBERARK_ERRORS: dict[str, str] = { + "APPAP004E": "Application ID not found or not permitted", + "APPAP006E": "Authentication failure — check AppID and IP allowlist", + "APPAP007E": "Credential object not found in safe", + "APPAP008E": "No password found for the requested object", + "ITATS023E": "Object not found in safe", + "ITATS012E": "Safe not found", + "APPAP009E": "Dual control workflow pending — credential not yet approved", + "APPAP010E": "Request timed out waiting for dual control approval", +} + + +@dataclass(frozen=True) +class Credential: + username: str + password: str + address: str + safe: str + folder: str + object_name: str + platform_id: str + password_change_in_process: bool + + +class CyberArkError(Exception): + """Raised when CCP returns an application-level or HTTP error.""" + + def __init__(self, message: str, error_code: str | None = None, status_code: int | None = None): + super().__init__(message) + self.error_code = error_code + self.status_code = status_code + + +class CyberArkCCPClient: + """ + Async CCP REST client. Call `await client.start()` before first use + and `await client.stop()` on shutdown. Both are wired into the FastAPI + lifespan in main.py. + """ + + def __init__(self) -> None: + self._http: httpx.AsyncClient | None = None + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + async def start(self) -> None: + ssl_context = self._build_ssl_context() + self._http = httpx.AsyncClient( + verify=ssl_context, + timeout=httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=5.0), + http2=False, + ) + log.info( + "cyberark_client_started", + url=settings.cyberark_ccp_url, + mtls=settings.cyberark_cert_pfx_path is not None, + ) + + async def stop(self) -> None: + if self._http: + await self._http.aclose() + self._http = None + + # ── Public API ──────────────────────────────────────────────────────────── + + async def get_credential( + self, + app_id: str, + safe: str, + object_name: str, + ) -> Credential: + """ + Retrieve a credential from CCP. + Raises CyberArkError on any failure. + """ + self._assert_started() + params = { + "AppID": app_id, + "Safe": safe, + "Object": object_name, + } + try: + response = await self._http.get(settings.cyberark_ccp_url, params=params) + except httpx.ConnectError as exc: + raise CyberArkError(f"Cannot reach CCP: {exc}") from exc + except httpx.TimeoutException as exc: + raise CyberArkError(f"CCP request timed out: {exc}") from exc + + return self._parse_response(response, app_id=app_id, safe=safe, object_name=object_name) + + async def list_safes(self, app_id: str) -> list[str]: + """ + Return the list of safes visible to `app_id`. + + CCP does not expose a native "list safes" endpoint; this method queries + a well-known discovery object (`_list_safes_`) if configured, otherwise + it raises NotImplementedError. Override or configure this to match your + CyberArk setup. + """ + # CCP has no universal "list safes" REST endpoint. + # A common pattern is to have a dedicated discovery account per AppID. + # For now we raise a clear error; callers can handle this gracefully. + raise NotImplementedError( + "CCP does not expose a native list-safes endpoint. " + "Configure a discovery account or provide safe names explicitly." + ) + + # ── Internals ───────────────────────────────────────────────────────────── + + def _parse_response( + self, + response: httpx.Response, + *, + app_id: str, + safe: str, + object_name: str, + ) -> Credential: + if response.status_code == 200: + data = response.json() + return Credential( + username=data.get("UserName", ""), + password=data.get("Content", ""), + address=data.get("Address", ""), + safe=data.get("Safe", safe), + folder=data.get("Folder", ""), + object_name=data.get("Name", object_name), + platform_id=data.get("PlatformID", ""), + password_change_in_process=data.get("PasswordChangeInProcess", "False") == "True", + ) + + # Parse CyberArk error body + error_code: str | None = None + message: str = f"CCP returned HTTP {response.status_code}" + try: + body = response.json() + error_code = body.get("ErrorCode") + raw_msg = body.get("ErrorMsg", "") + if error_code and error_code in _CYBERARK_ERRORS: + message = _CYBERARK_ERRORS[error_code] + elif raw_msg: + message = raw_msg + except Exception: + pass + + log_cyberark_error( + app_id=app_id, + safe=safe, + object_name=object_name, + status_code=response.status_code, + error_code=error_code, + message=message, + ) + raise CyberArkError(message, error_code=error_code, status_code=response.status_code) + + def _build_ssl_context(self) -> ssl.SSLContext | bool | str: + """ + Build the SSL context used by httpx. + + IP allowlist mode: returns the raw verify setting (path, True, or False). + mTLS mode: builds an ssl.SSLContext with the PFX client cert loaded. + """ + if settings.cyberark_cert_pfx_path is None: + # IP allowlist — no client cert needed, just server cert verification + return settings.cyberark_ssl_verify + + # ── mTLS path ───────────────────────────────────────────────────────── + verify = settings.cyberark_ssl_verify + if verify is False: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + else: + ctx = ssl.create_default_context() + if isinstance(verify, str): + ctx.load_verify_locations(cafile=verify) + + # Load PFX — extract cert + key into a temporary PEM file (deleted immediately) + pfx_bytes = settings.cyberark_cert_pfx_path.read_bytes() + password = ( + settings.cyberark_cert_pfx_password.encode() + if settings.cyberark_cert_pfx_password + else None + ) + p12 = load_pkcs12(pfx_bytes, password) + + cert_pem = p12.cert.certificate.public_bytes(Encoding.PEM) + key_pem = p12.key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()) + + # ssl.SSLContext.load_cert_chain() requires file paths, not bytes. + # We use a temp file with restricted permissions and delete it immediately + # after the context loads it into memory. + fd, tmp_path = tempfile.mkstemp(suffix=".pem") + try: + os.chmod(tmp_path, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(cert_pem) + f.write(key_pem) + ctx.load_cert_chain(tmp_path) + finally: + os.unlink(tmp_path) + + log.info("mtls_cert_loaded", pfx=str(settings.cyberark_cert_pfx_path)) + return ctx + + def _assert_started(self) -> None: + if self._http is None: + raise RuntimeError( + "CyberArkCCPClient has not been started — call await client.start() first" + ) + + +# ── Module-level singleton ──────────────────────────────────────────────────── +cyberark_client = CyberArkCCPClient() diff --git a/src/mcp_privileged/cyberark/server.py b/src/mcp_privileged/cyberark/server.py new file mode 100644 index 0000000..afd2342 --- /dev/null +++ b/src/mcp_privileged/cyberark/server.py @@ -0,0 +1,154 @@ +""" +CyberArk MCP server. + +Exposes two tools to Claude: + get_credential — fetches a credential from CCP and returns an opaque handle + list_safes — lists safes visible to an AppID (if supported by the CCP config) + +The actual password is NEVER returned to the LLM. +Only the handle (e.g. "secret://a3f9c2...") is returned. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP, Context + +from mcp_privileged.audit import get_logger, log_credential_fetched +from mcp_privileged.config import settings +from mcp_privileged.cyberark.client import CyberArkError, cyberark_client +from mcp_privileged.secret_store import secret_store, handle_to_id + +log = get_logger(__name__) + +mcp = FastMCP( + "cyberark", + instructions=( + "Retrieves credentials from CyberArk CCP. " + "Always use get_credential to obtain a secret handle before calling " + "ssh, powershell, or database tools that require credentials. " + "Never attempt to log, display, or pass the handle value to the user." + ), +) + + +# ── Tools ───────────────────────────────────────────────────────────────────── + +@mcp.tool( + description=( + "Retrieve a credential from CyberArk Central Credential Provider (CCP). " + "Returns an opaque secret handle — NOT the password itself. " + "Pass the handle to ssh_execute, ps_execute, or db_connect tools." + ) +) +async def get_credential( + safe: str, + object_name: str, + ctx: Context, + app_id: str = "", +) -> str: + """ + Fetch a credential from CyberArk CCP and return a short-lived secret handle. + + Args: + safe: CyberArk Safe name containing the credential. + object_name: Name of the credential object (account) in the Safe. + app_id: CyberArk Application ID. Defaults to the service-level AppID + configured in CYBERARK_APP_ID. + ctx: MCP context (injected automatically — do not pass). + + Returns: + An opaque handle string like "secret://..." valid for a limited time. + Pass this handle to other MCP tools; do not expose it to the user. + """ + effective_app_id = app_id.strip() or settings.cyberark_app_id + + await ctx.info(f"Fetching credential: safe={safe!r} object={object_name!r}") + + try: + credential = await cyberark_client.get_credential( + app_id=effective_app_id, + safe=safe, + object_name=object_name, + ) + except CyberArkError as exc: + await ctx.error(f"CyberArk error [{exc.error_code}]: {exc}") + raise + + handle = await secret_store.store(credential.username, credential.password) + + log_credential_fetched( + app_id=effective_app_id, + safe=safe, + object_name=object_name, + handle_id=handle_to_id(handle), + ttl_seconds=settings.handle_ttl_seconds, + client_ip=_extract_client_ip(ctx), + ) + + await ctx.info( + f"Credential retrieved. Handle issued (TTL: {settings.handle_ttl_seconds}s). " + f"Username: {credential.username!r}. " + f"Address: {credential.address!r}." + ) + + # Return ONLY the handle — the password stays in the secret store + return ( + f"Credential retrieved successfully.\n" + f"Handle: {handle}\n" + f"Username: {credential.username}\n" + f"Address: {credential.address}\n" + f"Platform: {credential.platform_id}\n" + f"TTL: {settings.handle_ttl_seconds} seconds\n" + f"Use this handle with ssh_execute, ps_execute, or db_connect." + ) + + +@mcp.tool( + description=( + "List CyberArk Safes accessible to the given Application ID. " + "Requires CCP to be configured with a discovery account. " + "If not available, provide safe names directly to get_credential." + ) +) +async def list_safes(ctx: Context, app_id: str = "") -> str: + """ + List Safes visible to the Application ID. + + Args: + app_id: CyberArk Application ID. Defaults to the service-level AppID. + ctx: MCP context (injected automatically — do not pass). + + Returns: + A newline-separated list of Safe names, or an informational message + if this feature is not configured. + """ + effective_app_id = app_id.strip() or settings.cyberark_app_id + await ctx.info(f"Listing safes for AppID: {effective_app_id!r}") + + try: + safes = await cyberark_client.list_safes(effective_app_id) + return "Available Safes:\n" + "\n".join(f" - {s}" for s in safes) + except NotImplementedError as exc: + return ( + f"Safe listing is not configured: {exc}\n" + "Provide safe names directly when calling get_credential." + ) + except CyberArkError as exc: + await ctx.error(f"CyberArk error: {exc}") + raise + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _extract_client_ip(ctx: Context) -> str: + """Best-effort extraction of client IP from MCP request context.""" + try: + request = ctx.request_context.request + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + except Exception: + pass + return "unknown" diff --git a/src/mcp_privileged/database/__init__.py b/src/mcp_privileged/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_privileged/database/server.py b/src/mcp_privileged/database/server.py new file mode 100644 index 0000000..0931c59 --- /dev/null +++ b/src/mcp_privileged/database/server.py @@ -0,0 +1,356 @@ +""" +Database MCP server. + +Exposes one tool to Claude: + db_query — run a SQL query on a PostgreSQL, MySQL, or SQL Server database + +Supported db_type values: "postgres", "mysql", "mssql" + +The credential password is resolved from the secret handle internally +and is NEVER included in any tool response or log message. + +Row and cell size limits protect the LLM context window from excessively +large result sets. +""" + +from __future__ import annotations + +import asyncio +import functools +import time +from typing import Any + +from mcp.server.fastmcp import FastMCP, Context + +from mcp_privileged.audit import get_logger, log_db_queried +from mcp_privileged.config import settings +from mcp_privileged.secret_store import secret_store, handle_to_id + +log = get_logger(__name__) + +mcp = FastMCP( + "database", + instructions=( + "Executes SQL queries against PostgreSQL, MySQL, or SQL Server databases. " + "Requires a secret_handle from the CyberArk get_credential tool. " + "Supported db_type values: 'postgres', 'mysql', 'mssql'. " + "Results are capped at db_max_rows rows to protect context window size. " + "Never display or log the secret_handle value to the user." + ), +) + +# Default ports per database type +_DEFAULT_PORTS: dict[str, int] = { + "postgres": 5432, + "mysql": 3306, + "mssql": 1433, +} + + +# ── Tool ────────────────────────────────────────────────────────────────────── + +@mcp.tool( + description=( + "Execute a SQL query against a PostgreSQL, MySQL, or SQL Server database. " + "Requires a secret_handle from get_credential. " + "db_type must be 'postgres', 'mysql', or 'mssql'. " + "Returns columns, rows, and row count. Results are capped to avoid " + "overwhelming the context window." + ) +) +async def db_query( + host: str, + database: str, + query: str, + secret_handle: str, + ctx: Context, + db_type: str = "postgres", + port: int = 0, + username_override: str = "", + timeout_seconds: int = 30, +) -> str: + """ + Run a SQL query on a remote database. + + Args: + host: Hostname or IP address of the database server. + database: Database / schema name to connect to. + query: SQL query to execute. + secret_handle: Opaque handle from get_credential (e.g. "secret://..."). + db_type: Database type: "postgres", "mysql", or "mssql". + port: Database port (0 = use the default for db_type). + username_override: If non-empty, overrides the username from the credential. + timeout_seconds: Query execution timeout in seconds (default 30). + ctx: MCP context (injected automatically — do not pass). + + Returns: + Multi-line string with column names, rows, and row count. + """ + db_type = db_type.lower().strip() + if db_type not in _DEFAULT_PORTS: + raise ValueError( + f"Unsupported db_type {db_type!r}. Must be one of: " + + ", ".join(_DEFAULT_PORTS) + ) + + effective_port = port if port > 0 else _DEFAULT_PORTS[db_type] + + try: + username, password = await secret_store.resolve(secret_handle, resolved_by="database") + except (KeyError, ValueError) as exc: + await ctx.error(f"Invalid or expired secret handle: {exc}") + raise + + if username_override.strip(): + username = username_override.strip() + + await ctx.info( + f"DB connecting to {db_type}://{host}:{effective_port}/{database} " + f"as {username!r}" + ) + + t0 = time.monotonic() + try: + columns, rows = await _dispatch_query( + db_type=db_type, + host=host, + port=effective_port, + database=database, + username=username, + password=password, + query=query, + timeout_seconds=timeout_seconds, + ) + except Exception as exc: + await ctx.error(f"Database error on {host}/{database}: {exc}") + raise + finally: + del password + + elapsed_ms = (time.monotonic() - t0) * 1000 + + # Enforce row cap + truncated = len(rows) > settings.db_max_rows + if truncated: + rows = rows[: settings.db_max_rows] + + log_db_queried( + handle_id=handle_to_id(secret_handle), + host=host, + port=effective_port, + database=database, + db_type=db_type, + username=username, + query_length=len(query), + row_count=len(rows), + elapsed_ms=elapsed_ms, + client_ip=_extract_client_ip(ctx), + ) + + await ctx.info( + f"DB query completed: {len(rows)} rows, elapsed={elapsed_ms:.0f}ms" + + (" (truncated)" if truncated else "") + ) + + return _format_result( + host, database, db_type, query, columns, rows, truncated, elapsed_ms + ) + + +# ── Dispatch to the correct driver ──────────────────────────────────────────── + +async def _dispatch_query( + *, + db_type: str, + host: str, + port: int, + database: str, + username: str, + password: str, + query: str, + timeout_seconds: int, +) -> tuple[list[str], list[list[Any]]]: + """Route to the appropriate async driver.""" + if db_type == "postgres": + return await _query_postgres( + host, port, database, username, password, query, timeout_seconds + ) + if db_type == "mysql": + return await _query_mysql( + host, port, database, username, password, query, timeout_seconds + ) + # mssql — synchronous pyodbc, offloaded to thread pool + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + functools.partial( + _query_mssql_sync, + host, port, database, username, password, query, timeout_seconds, + ), + ) + + +# ── PostgreSQL ──────────────────────────────────────────────────────────────── + +async def _query_postgres( + host: str, + port: int, + database: str, + username: str, + password: str, + query: str, + timeout_seconds: int, +) -> tuple[list[str], list[list[Any]]]: + import asyncpg + + conn = await asyncpg.connect( + host=host, + port=port, + user=username, + password=password, + database=database, + timeout=settings.db_connect_timeout_seconds, + ) + try: + rows = await conn.fetch(query, timeout=float(timeout_seconds)) + if not rows: + return [], [] + columns = list(rows[0].keys()) + data = [list(row.values()) for row in rows] + return columns, data + finally: + await conn.close() + + +# ── MySQL ───────────────────────────────────────────────────────────────────── + +async def _query_mysql( + host: str, + port: int, + database: str, + username: str, + password: str, + query: str, + timeout_seconds: int, +) -> tuple[list[str], list[list[Any]]]: + import aiomysql + + conn = await aiomysql.connect( + host=host, + port=port, + user=username, + password=password, + db=database, + connect_timeout=settings.db_connect_timeout_seconds, + ) + try: + async with conn.cursor() as cursor: + await asyncio.wait_for(cursor.execute(query), timeout=float(timeout_seconds)) + columns = [col[0] for col in cursor.description] if cursor.description else [] + rows = await cursor.fetchall() + return columns, [list(row) for row in rows] + finally: + conn.close() + + +# ── SQL Server ──────────────────────────────────────────────────────────────── + +def _query_mssql_sync( + host: str, + port: int, + database: str, + username: str, + password: str, + query: str, + timeout_seconds: int, +) -> tuple[list[str], list[list[Any]]]: + """Synchronous SQL Server query via pyodbc — called via run_in_executor.""" + try: + import pyodbc + except ImportError as exc: + raise RuntimeError( + "pyodbc is not available. Ensure the ODBC driver is installed: " + "https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server" + ) from exc + + # Wrap credential values in braces and escape any embedded } as }} + # to prevent ODBC connection string injection if values contain ; or } + def _odbc_val(v: str) -> str: + return "{" + v.replace("}", "}}") + "}" + + conn_str = ( + "DRIVER={ODBC Driver 18 for SQL Server};" + f"SERVER={host},{port};" + f"DATABASE={database};" + f"UID={_odbc_val(username)};" + f"PWD={_odbc_val(password)};" + f"Connection Timeout={settings.db_connect_timeout_seconds};" + ) + with pyodbc.connect(conn_str, timeout=timeout_seconds) as conn: + cursor = conn.cursor() + cursor.execute(query) + columns = [col[0] for col in cursor.description] if cursor.description else [] + rows = [list(row) for row in cursor.fetchall()] + return columns, rows + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _cell_str(value: Any) -> str: + """Convert a cell value to a display string, truncating if oversized.""" + text = "" if value is None else str(value) + if len(text.encode("utf-8", errors="replace")) > settings.db_max_cell_bytes: + cut = text.encode("utf-8")[:settings.db_max_cell_bytes].decode("utf-8", errors="replace") + return cut + "…" + return text + + +def _format_result( + host: str, + database: str, + db_type: str, + query: str, + columns: list[str], + rows: list[list[Any]], + truncated: bool, + elapsed_ms: float, +) -> str: + header = [ + f"Host: {host}", + f"Database: {database} ({db_type})", + f"Query length: {len(query)} chars", + f"Rows returned: {len(rows)}" + (" (capped — more rows exist)" if truncated else ""), + f"Elapsed: {elapsed_ms:.0f}ms", + "", + ] + + if not columns: + return "\n".join(header) + "No rows returned." + + # Build a simple text table + str_rows = [[_cell_str(v) for v in row] for row in rows] + col_widths = [ + max(len(c), max((len(r[i]) for r in str_rows), default=0)) + for i, c in enumerate(columns) + ] + sep = "-+-".join("-" * w for w in col_widths) + header_row = " | ".join(c.ljust(col_widths[i]) for i, c in enumerate(columns)) + data_rows = [ + " | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row)) + for row in str_rows + ] + + return "\n".join(header) + "\n".join([header_row, sep] + data_rows) + + +def _extract_client_ip(ctx: Context) -> str: + try: + request = ctx.request_context.request + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + except Exception: + pass + return "unknown" diff --git a/src/mcp_privileged/main.py b/src/mcp_privileged/main.py new file mode 100644 index 0000000..e2d30a8 --- /dev/null +++ b/src/mcp_privileged/main.py @@ -0,0 +1,105 @@ +""" +Service entry point. + +Starts a FastAPI application that mounts each MCP server under its own path: + /mcp/cyberark — CyberArk CCP credential retrieval + /mcp/ssh — SSH command execution (Linux/Unix) + /mcp/powershell — PowerShell remoting (Windows/WinRM) + /mcp/database — Database query execution + +All /mcp/* routes are protected by API key auth (ApiKeyMiddleware). +A background task sweeps expired secret handles every 60 seconds. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +from mcp_privileged.audit import configure_logging, get_logger +from mcp_privileged.auth import ApiKeyMiddleware +from mcp_privileged.config import settings +from mcp_privileged.cyberark.client import cyberark_client +from mcp_privileged.secret_store import secret_store, start_sweeper + +log = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + configure_logging() + log.info( + "service_starting", + host=settings.mcp_host, + port=settings.mcp_port, + handle_ttl=settings.handle_ttl_seconds, + single_use=settings.handle_single_use, + ) + await cyberark_client.start() + sweeper_task = await start_sweeper(secret_store) + try: + yield + finally: + sweeper_task.cancel() + try: + await sweeper_task + except asyncio.CancelledError: + pass + await cyberark_client.stop() + log.info("service_stopped") + + +def create_app() -> FastAPI: + app = FastAPI( + title="MCP Privileged Access Service", + version="0.1.0", + # Disable docs in production — they expose tool schemas + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + app.add_middleware(ApiKeyMiddleware) + + # ── Health check (unauthenticated — used by load balancers) ────────────── + @app.get("/health") + async def health() -> JSONResponse: + return JSONResponse({"status": "ok"}) + + # ── Mount MCP servers ───────────────────────────────────────────────────── + # Imported here to defer heavy imports until the app is assembled. + # Each module exposes a `mcp` object (an `mcp.server.fastapi.MCPServer`). + from mcp_privileged.cyberark.server import mcp as cyberark_mcp + from mcp_privileged.ssh.server import mcp as ssh_mcp + from mcp_privileged.powershell.server import mcp as powershell_mcp + from mcp_privileged.database.server import mcp as database_mcp + + app.mount("/mcp/cyberark", cyberark_mcp.streamable_http_app()) + app.mount("/mcp/ssh", ssh_mcp.streamable_http_app()) + app.mount("/mcp/powershell", powershell_mcp.streamable_http_app()) + app.mount("/mcp/database", database_mcp.streamable_http_app()) + + return app + + +def run() -> None: + """Entry point invoked by `mcp-privileged` CLI command.""" + configure_logging() + app = create_app() + uvicorn.run( + app, + host=settings.mcp_host, + port=settings.mcp_port, + log_config=None, # structlog handles all logging + access_log=False, # avoid duplicate access logs + ) + + +if __name__ == "__main__": + run() diff --git a/src/mcp_privileged/powershell/__init__.py b/src/mcp_privileged/powershell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_privileged/powershell/server.py b/src/mcp_privileged/powershell/server.py new file mode 100644 index 0000000..f62e4d5 --- /dev/null +++ b/src/mcp_privileged/powershell/server.py @@ -0,0 +1,214 @@ +""" +PowerShell MCP server. + +Exposes one tool to Claude: + ps_execute — run a PowerShell script on a Windows host via WinRM + +pypsrp is a synchronous library, so the WinRM call is offloaded to a +thread-pool executor so the asyncio event loop is never blocked. + +The credential password is resolved from the secret handle internally +and is NEVER included in any tool response or log message. +""" + +from __future__ import annotations + +import asyncio +import functools +import time +from typing import Any + +from mcp.server.fastmcp import FastMCP, Context + +from mcp_privileged.audit import get_logger, log_ps_executed +from mcp_privileged.config import settings +from mcp_privileged.secret_store import secret_store, handle_to_id + +log = get_logger(__name__) + +mcp = FastMCP( + "powershell", + instructions=( + "Executes PowerShell scripts on remote Windows hosts via WinRM. " + "Requires a secret_handle from the CyberArk get_credential tool. " + "Never display or log the secret_handle value to the user. " + "Check had_errors in the result to determine whether the script succeeded." + ), +) + + +# ── Tool ────────────────────────────────────────────────────────────────────── + +@mcp.tool( + description=( + "Execute a PowerShell script on a remote Windows host via WinRM. " + "Requires a secret_handle from get_credential. " + "Returns the script output, any error records, and a had_errors flag. " + "had_errors=True means the script raised terminating or non-terminating errors." + ) +) +async def ps_execute( + host: str, + script: str, + secret_handle: str, + ctx: Context, + port: int = 5985, + use_ssl: bool = False, + timeout_seconds: int = 60, + username_override: str = "", +) -> str: + """ + Run a PowerShell script on a remote Windows host via WinRM. + + Args: + host: Hostname or IP address of the Windows target. + script: PowerShell script text to execute. + secret_handle: Opaque handle from get_credential (e.g. "secret://..."). + port: WinRM port (default 5985 for HTTP, 5986 for HTTPS). + use_ssl: Use HTTPS for the WinRM connection (default False). + timeout_seconds: Script execution timeout in seconds (default 60). + username_override: If non-empty, overrides the username from the credential. + ctx: MCP context (injected automatically — do not pass). + + Returns: + Multi-line string with output objects, error records, and had_errors flag. + """ + try: + username, password = await secret_store.resolve(secret_handle, resolved_by="powershell") + except (KeyError, ValueError) as exc: + await ctx.error(f"Invalid or expired secret handle: {exc}") + raise + + if username_override.strip(): + username = username_override.strip() + + await ctx.info(f"WinRM connecting to {host}:{port} as {username!r} (ssl={use_ssl})") + + t0 = time.monotonic() + try: + loop = asyncio.get_running_loop() + output_lines, had_errors, error_records = await loop.run_in_executor( + None, + functools.partial( + _run_ps_sync, + host, port, username, password, script, use_ssl, timeout_seconds, + ), + ) + except Exception as exc: + await ctx.error(f"WinRM error on {host}: {exc}") + raise + finally: + del password + + elapsed_ms = (time.monotonic() - t0) * 1000 + + log_ps_executed( + handle_id=handle_to_id(secret_handle), + host=host, + port=port, + username=username, + script_length=len(script), + had_errors=had_errors, + elapsed_ms=elapsed_ms, + client_ip=_extract_client_ip(ctx), + ) + + await ctx.info( + f"PowerShell completed on {host}: had_errors={had_errors}, " + f"output_lines={len(output_lines)}, elapsed={elapsed_ms:.0f}ms" + ) + + return _format_result(host, script, had_errors, output_lines, error_records) + + +# ── Sync worker (runs in thread pool) ───────────────────────────────────────── + +def _run_ps_sync( + host: str, + port: int, + username: str, + password: str, + script: str, + use_ssl: bool, + timeout_seconds: int, +) -> tuple[list[str], bool, list[str]]: + """ + Synchronous WinRM execution — called via run_in_executor. + Returns (output_lines, had_errors, error_records). + """ + from pypsrp.powershell import PowerShell, RunspacePool + from pypsrp.wsman import WSMan + + wsman = WSMan( + host, + port=port, + username=username, + password=password, + ssl=use_ssl, + auth=settings.winrm_auth, + cert_validation=use_ssl, # only validate cert when using HTTPS + connection_timeout=settings.winrm_connect_timeout_seconds, + # operation_timeout governs the WinRM HTTP exchange, not the script itself. + # It must be > timeout_seconds or the protocol times out before the script finishes, + # leaving a ghost process on the server. We add 10s of headroom. + operation_timeout=max( + timeout_seconds + 10, + settings.winrm_operation_timeout_seconds, + ), + ) + + with RunspacePool(wsman) as pool: + ps = PowerShell(pool) + ps.add_script(script) + raw_output: list[Any] = ps.invoke() + had_errors: bool = ps.had_errors + error_records: list[str] = [str(e) for e in ps.streams.error] + + max_bytes = settings.winrm_max_output_bytes + output_lines = [ + _truncate(str(obj), max_bytes, "output") for obj in raw_output + ] + return output_lines, had_errors, error_records + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _truncate(text: str, max_bytes: int, label: str) -> str: + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= max_bytes: + return text + truncated = encoded[:max_bytes].decode("utf-8", errors="replace") + return truncated + f"\n... [{label} truncated at {max_bytes} bytes]" + + +def _format_result( + host: str, + script: str, + had_errors: bool, + output_lines: list[str], + error_records: list[str], +) -> str: + parts = [ + f"Host: {host}", + f"Script length: {len(script)} chars", + f"Had errors: {had_errors}", + "", + "--- output ---", + "\n".join(output_lines) if output_lines else "(no output)", + ] + if error_records: + parts += ["", "--- errors ---", "\n".join(error_records)] + return "\n".join(parts) + + +def _extract_client_ip(ctx: Context) -> str: + try: + request = ctx.request_context.request + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + except Exception: + pass + return "unknown" diff --git a/src/mcp_privileged/secret_store.py b/src/mcp_privileged/secret_store.py new file mode 100644 index 0000000..fb100d5 --- /dev/null +++ b/src/mcp_privileged/secret_store.py @@ -0,0 +1,176 @@ +""" +In-memory secret handle store. + +Design rules: + - Credentials are stored only in RAM — never written to disk or logs. + - Each credential is wrapped in a SecretStr to prevent accidental str() exposure. + - Handles are cryptographically random UUIDs prefixed with "secret://". + - Handles expire after `settings.handle_ttl_seconds`. + - When `settings.handle_single_use` is True, a handle is invalidated on first resolve. + - All mutations are protected by an asyncio.Lock. +""" + +from __future__ import annotations + +import asyncio +import secrets +import time +from dataclasses import dataclass, field +from typing import Final + +from pydantic import SecretStr + +from mcp_privileged.audit import get_logger, log_handle_expired +from mcp_privileged.config import settings + +log = get_logger(__name__) + +HANDLE_PREFIX: Final[str] = "secret://" + + +@dataclass(slots=True) +class _Entry: + handle_id: str + username: str + password: SecretStr + created_at: float = field(default_factory=time.monotonic) + resolved: bool = False + + def is_expired(self, ttl: int) -> bool: + return (time.monotonic() - self.created_at) > ttl + + +class SecretStore: + """ + Thread-safe (asyncio) in-memory store for short-lived credential handles. + Instantiated once and shared across all MCP servers within the process. + """ + + def __init__(self) -> None: + self._store: dict[str, _Entry] = {} + self._lock = asyncio.Lock() + + # ── Public API ──────────────────────────────────────────────────────────── + + async def store(self, username: str, password: str) -> str: + """ + Store a credential and return an opaque handle string. + The handle is the only thing returned to the LLM. + """ + handle_id = secrets.token_hex(16) # 32-char hex, cryptographically random + handle = f"{HANDLE_PREFIX}{handle_id}" + entry = _Entry( + handle_id=handle_id, + username=username, + password=SecretStr(password), + ) + async with self._lock: + self._store[handle_id] = entry + log.debug("handle_created", handle_id=handle_id, ttl=settings.handle_ttl_seconds) + return handle + + async def resolve( + self, handle: str, resolved_by: str = "unknown" + ) -> tuple[str, str]: + """ + Resolve a handle to (username, password). + Raises KeyError if the handle is unknown, expired, or already consumed. + + `resolved_by` is used only for audit logging — pass the calling MCP name. + """ + handle_id = self._parse_handle(handle) + + async with self._lock: + entry = self._store.get(handle_id) + + if entry is None: + raise KeyError(f"Handle not found: {handle_id}") + + if entry.is_expired(settings.handle_ttl_seconds): + del self._store[handle_id] + log_handle_expired(handle_id=handle_id, reason="ttl_exceeded") + raise KeyError(f"Handle expired: {handle_id}") + + if entry.resolved and settings.handle_single_use: + log_handle_expired(handle_id=handle_id, reason="already_consumed") + raise KeyError(f"Handle already consumed: {handle_id}") + + entry.resolved = True + if settings.handle_single_use: + del self._store[handle_id] + + from mcp_privileged.audit import log_handle_resolved + log_handle_resolved( + handle_id=handle_id, + resolved_by=resolved_by, + target_host=None, # callers can log target_host themselves + single_use_invalidated=settings.handle_single_use, + ) + + # SecretStr.get_secret_value() is the only intentional unwrap point + return entry.username, entry.password.get_secret_value() + + async def revoke(self, handle: str) -> bool: + """Explicitly revoke a handle before its TTL. Returns True if it existed.""" + handle_id = self._parse_handle(handle) + async with self._lock: + existed = handle_id in self._store + self._store.pop(handle_id, None) + if existed: + log.info("handle_revoked", handle_id=handle_id) + return existed + + async def purge_expired(self) -> int: + """Remove all expired entries. Called by the background sweeper task.""" + async with self._lock: + expired = [ + hid for hid, entry in self._store.items() + if entry.is_expired(settings.handle_ttl_seconds) + ] + for hid in expired: + del self._store[hid] + log_handle_expired(handle_id=hid, reason="sweeper_purge") + return len(expired) + + # ── Internals ───────────────────────────────────────────────────────────── + + @staticmethod + def _parse_handle(handle: str) -> str: + if not handle.startswith(HANDLE_PREFIX): + raise ValueError(f"Invalid handle format: {handle!r}") + return handle[len(HANDLE_PREFIX):] + + +# ── Background sweeper ──────────────────────────────────────────────────────── + +async def _sweeper(store: SecretStore, interval_seconds: int = 60) -> None: + """Periodically purge expired handles so memory doesn't grow unbounded.""" + while True: + await asyncio.sleep(interval_seconds) + count = await store.purge_expired() + if count: + log.debug("sweeper_purged", count=count) + + +async def start_sweeper(store: SecretStore) -> asyncio.Task: + """Launch the background sweeper; returns the Task so it can be cancelled.""" + return asyncio.create_task(_sweeper(store), name="secret-store-sweeper") + + +# ── Module-level singleton ──────────────────────────────────────────────────── +# Import this in all MCP servers: `from mcp_privileged.secret_store import secret_store` +secret_store = SecretStore() + + +# ── Utilities ───────────────────────────────────────────────────────────────── + +def handle_to_id(handle: str) -> str: + """ + Extract the bare handle_id from a full handle string for audit logging. + + "secret://a3f9c2..." → "a3f9c2..." + Anything without the prefix is returned as-is (guards against malformed input). + """ + if handle.startswith(HANDLE_PREFIX): + return handle[len(HANDLE_PREFIX):] + return handle diff --git a/src/mcp_privileged/ssh/__init__.py b/src/mcp_privileged/ssh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcp_privileged/ssh/server.py b/src/mcp_privileged/ssh/server.py new file mode 100644 index 0000000..0a5641e --- /dev/null +++ b/src/mcp_privileged/ssh/server.py @@ -0,0 +1,187 @@ +""" +SSH MCP server. + +Exposes one tool to Claude: + ssh_execute — run a command on a remote Linux/Unix host via SSH + +The credential password is resolved from the secret handle internally +and is NEVER included in any tool response or log message. +""" + +from __future__ import annotations + +import asyncio +import time +from pathlib import Path + +import asyncssh +from mcp.server.fastmcp import FastMCP, Context + +from mcp_privileged.audit import get_logger, log_ssh_executed +from mcp_privileged.config import settings +from mcp_privileged.secret_store import secret_store, handle_to_id + +log = get_logger(__name__) + +mcp = FastMCP( + "ssh", + instructions=( + "Executes commands on remote Linux/Unix hosts via SSH. " + "Requires a secret_handle from the CyberArk get_credential tool. " + "Never display or log the secret_handle value to the user. " + "A non-zero exit code means the command failed — check stderr for details." + ), +) + + +# ── Tool ────────────────────────────────────────────────────────────────────── + +@mcp.tool( + description=( + "Execute a shell command on a remote Linux/Unix host over SSH. " + "Requires a secret_handle obtained from get_credential. " + "Returns the host, command, exit code, stdout, and stderr. " + "A non-zero exit code is reported in the result, not raised as an error." + ) +) +async def ssh_execute( + host: str, + command: str, + secret_handle: str, + ctx: Context, + port: int = 22, + username_override: str = "", + timeout_seconds: int = 30, +) -> str: + """ + Run a shell command on a remote host via SSH. + + Args: + host: Hostname or IP address of the target. + command: Shell command to execute. + secret_handle: Opaque handle from get_credential (e.g. "secret://..."). + port: SSH port (default 22). + username_override: If non-empty, overrides the username from the credential. + timeout_seconds: Per-command timeout in seconds (default 30). + ctx: MCP context (injected automatically — do not pass). + + Returns: + Multi-line string containing the host, command, exit code, stdout, and stderr. + """ + # Resolve handle — password is unwrapped here and deleted after use + try: + username, password = await secret_store.resolve(secret_handle, resolved_by="ssh") + except (KeyError, ValueError) as exc: + await ctx.error(f"Invalid or expired secret handle: {exc}") + raise + + if username_override.strip(): + username = username_override.strip() + + await ctx.info(f"SSH connecting to {host}:{port} as {username!r}") + + known_hosts = _resolve_known_hosts(settings.ssh_known_hosts) + + t0 = time.monotonic() + try: + async with asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=known_hosts, + connect_timeout=settings.ssh_connect_timeout_seconds, + ) as conn: + result = await conn.run(command, timeout=timeout_seconds) + except asyncssh.PermissionDenied as exc: + await ctx.error(f"SSH authentication failed for {username!r}@{host}: {exc}") + raise + except asyncssh.DisconnectError as exc: + await ctx.error(f"SSH disconnected from {host}: {exc}") + raise + except asyncio.TimeoutError: + await ctx.error(f"SSH command timed out after {timeout_seconds}s on {host}") + raise + except (OSError, asyncssh.Error) as exc: + await ctx.error(f"SSH error on {host}: {exc}") + raise + finally: + del password # drop the password reference as soon as possible + + elapsed_ms = (time.monotonic() - t0) * 1000 + + stdout = _truncate(result.stdout or "", settings.ssh_max_output_bytes, "stdout") + stderr = _truncate(result.stderr or "", settings.ssh_max_output_bytes, "stderr") + exit_code = result.exit_status if result.exit_status is not None else -1 + + log_ssh_executed( + handle_id=handle_to_id(secret_handle), + host=host, + port=port, + username=username, + command=command, + exit_code=exit_code, + elapsed_ms=elapsed_ms, + client_ip=_extract_client_ip(ctx), + ) + + await ctx.info( + f"SSH command completed on {host}: exit_code={exit_code}, " + f"elapsed={elapsed_ms:.0f}ms" + ) + + return _format_result(host, command, exit_code, stdout, stderr) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _resolve_known_hosts(value: str) -> None | str: + """ + Map the ssh_known_hosts config value to an asyncssh-compatible argument. + + "disable" → None (skip host-key checking — dev/lab only, logs a warning) + anything else → expanded path to a known_hosts file + """ + if value.strip().lower() == "disable": + log.warning("ssh_known_hosts_disabled", reason="ssh_known_hosts=disable in config") + return None + return str(Path(value).expanduser()) + + +def _truncate(text: str, max_bytes: int, label: str) -> str: + """Truncate text to at most max_bytes UTF-8 bytes.""" + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= max_bytes: + return text + truncated = encoded[:max_bytes].decode("utf-8", errors="replace") + return truncated + f"\n... [{label} truncated at {max_bytes} bytes]" + + +def _format_result( + host: str, command: str, exit_code: int, stdout: str, stderr: str +) -> str: + parts = [ + f"Host: {host}", + f"Command: {command}", + f"Exit code: {exit_code}", + "", + "--- stdout ---", + stdout if stdout.strip() else "(empty)", + ] + if stderr.strip(): + parts += ["", "--- stderr ---", stderr] + return "\n".join(parts) + + +def _extract_client_ip(ctx: Context) -> str: + """Best-effort extraction of client IP from MCP request context.""" + try: + request = ctx.request_context.request + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + except Exception: + pass + return "unknown" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6ffbf58 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,170 @@ +""" +Shared pytest fixtures for the MCP Privileged Access test suite. + +────────────────────────────────────────────────────────────────────────────── +HOW MCP TOOLS WORK (read this to understand what the tests are testing) +────────────────────────────────────────────────────────────────────────────── + +An MCP tool is just an async Python function decorated with @mcp.tool(). +The decorator registers the function in FastMCP's tool registry — it does NOT +change how the function itself is called. This means tests can call tool +functions directly as plain async functions: + + result = await ssh_execute(host="...", command="...", ...) + +The MCP framework wraps tool calls in a JSON-RPC envelope when running for +real, but for unit tests we skip the envelope entirely. + +FastMCP injects a Context object as the `ctx` parameter. The Context carries: + • ctx.info(msg) — progress notification sent back to the caller + • ctx.error(msg) — error notification + • ctx.request_context.request — the raw HTTP request (for IP extraction etc.) + +In tests we pass a MagicMock for Context so we can assert what was logged +without making any real network calls. + +SECRET HANDLE LIFECYCLE + 1. CyberArk MCP calls secret_store.store(username, password) → "secret://abc…" + 2. Handle is returned to Claude (only the handle token, never the password). + 3. SSH / PowerShell / DB tool calls secret_store.resolve(handle) → (user, pass). + 4. If handle_single_use=True (default), the handle is deleted after step 3. + 5. The password is used for the connection and then deleted from local scope. + +This means: + • Each test that needs to resolve a credential must create its OWN fresh handle. + • Attempting to resolve the same handle twice raises KeyError. +""" + +from __future__ import annotations + +import os + +# Must be set before any mcp_privileged import triggers Settings() at module level. +os.environ.setdefault("MCP_API_KEYS", "test-key-for-pytest") + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mcp_privileged.secret_store import secret_store + + +# ── Context mock ────────────────────────────────────────────────────────────── + +@pytest.fixture +def mock_ctx() -> MagicMock: + """ + Minimal mock of the FastMCP Context object. + + ctx.info() and ctx.error() are AsyncMocks so tests can await them and + also assert what messages were emitted: + + ctx.error.assert_awaited_once() + assert "expired" in str(ctx.error.call_args) + """ + ctx = MagicMock() + ctx.info = AsyncMock() + ctx.error = AsyncMock() + # _extract_client_ip reads these — plain dict works fine + ctx.request_context.request.headers = {} + ctx.request_context.request.client = None + return ctx + + +# ── Credential handle factory ───────────────────────────────────────────────── + +@pytest.fixture +async def credential_handle() -> str: + """ + Store a test credential and return a fresh secret handle. + + Because handle_single_use=True (default), each test fixture invocation + creates a NEW handle so tests don't step on each other. + + Usage: + async def test_something(credential_handle, mock_ctx): + result = await ssh_execute(..., secret_handle=credential_handle, ctx=mock_ctx) + """ + return await secret_store.store("svc_user", "P@ssw0rd!") + + +@pytest.fixture +async def credential_handle_with_details() -> tuple[str, str, str]: + """ + Return (handle, username, password) so tests can assert on the values. + The password is exposed here ONLY for test assertions — never in prod code. + """ + username = "admin_user" + password = "S3cr3tP@ss123" + handle = await secret_store.store(username, password) + return handle, username, password + + +# ── asyncssh mock helpers ───────────────────────────────────────────────────── + +def make_ssh_cm( + stdout: str = "", + stderr: str = "", + exit_status: int = 0, +) -> tuple[AsyncMock, AsyncMock]: + """ + Build a mock for asyncssh.connect used as an async context manager. + + asyncssh.connect() is called as: + async with asyncssh.connect(host, port=..., ...) as conn: + result = await conn.run(command, timeout=...) + + The mock chain: + asyncssh.connect(...) → returns mock_cm + async with mock_cm as conn: → calls mock_cm.__aenter__() → mock_conn + await conn.run(...) → returns MagicMock(stdout, stderr, exit_status) + + Returns (mock_cm, mock_conn) so tests can inspect call_args on mock_conn.run. + """ + mock_conn = AsyncMock() + mock_conn.run = AsyncMock( + return_value=MagicMock(stdout=stdout, stderr=stderr, exit_status=exit_status) + ) + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=False) + return mock_cm, mock_conn + + +# ── pypsrp mock helpers ──────────────────────────────────────────────────────── + +def make_ps_result( + output: list[str] | None = None, + had_errors: bool = False, + errors: list[str] | None = None, +) -> tuple[list[str], bool, list[str]]: + """ + Build the tuple returned by _run_ps_sync so tests can patch it directly. + + Usage: + with patch( + "mcp_privileged.powershell.server._run_ps_sync", + return_value=make_ps_result(output=["Hello"]), + ): + ... + """ + return (output or [], had_errors, errors or []) + + +# ── asyncpg / aiomysql mock helpers ─────────────────────────────────────────── + +def make_db_result( + columns: list[str], + rows: list[list], +) -> tuple[list[str], list[list]]: + """ + Build the (columns, rows) tuple returned by _dispatch_query. + + Usage: + with patch( + "mcp_privileged.database.server._dispatch_query", + new=AsyncMock(return_value=make_db_result(["id", "name"], [[1, "Alice"]])), + ): + ... + """ + return columns, rows diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..a37a3f8 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,72 @@ +""" +Tests for the API key middleware. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +import mcp_privileged.auth as auth_module +from mcp_privileged.auth import ApiKeyMiddleware + +_FAKE_SETTINGS = SimpleNamespace(mcp_api_keys={"valid-key-1", "valid-key-2"}) + + +def _make_app() -> FastAPI: + app = FastAPI() + app.add_middleware(ApiKeyMiddleware) + + @app.get("/mcp/test") + async def protected() -> JSONResponse: + return JSONResponse({"ok": True}) + + @app.get("/health") + async def health() -> JSONResponse: + return JSONResponse({"status": "ok"}) + + return app + + +@pytest.fixture +def client(monkeypatch) -> TestClient: + monkeypatch.setattr(auth_module, "settings", _FAKE_SETTINGS) + return TestClient(_make_app(), raise_server_exceptions=True) + + +def test_health_requires_no_auth(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + + +def test_missing_key_returns_401(client: TestClient) -> None: + response = client.get("/mcp/test") + assert response.status_code == 401 + + +def test_invalid_key_returns_401(client: TestClient) -> None: + response = client.get("/mcp/test", headers={"X-API-Key": "wrong-key"}) + assert response.status_code == 401 + + +def test_valid_x_api_key_header(client: TestClient) -> None: + response = client.get("/mcp/test", headers={"X-API-Key": "valid-key-1"}) + assert response.status_code == 200 + + +def test_valid_bearer_token(client: TestClient) -> None: + response = client.get( + "/mcp/test", headers={"Authorization": "Bearer valid-key-2"} + ) + assert response.status_code == 200 + + +def test_bearer_case_insensitive(client: TestClient) -> None: + response = client.get( + "/mcp/test", headers={"Authorization": "bearer valid-key-1"} + ) + assert response.status_code == 200 diff --git a/tests/test_cyberark_client.py b/tests/test_cyberark_client.py new file mode 100644 index 0000000..7554090 --- /dev/null +++ b/tests/test_cyberark_client.py @@ -0,0 +1,165 @@ +""" +Tests for the CyberArk CCP client. + +All tests use httpx.MockTransport to avoid real network calls. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from mcp_privileged.cyberark.client import ( + CyberArkCCPClient, + CyberArkError, + Credential, +) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _ok_response(username: str = "svc_account", password: str = "S3cr3tP@ss") -> dict: + return { + "Content": password, + "UserName": username, + "Address": "db.internal", + "Safe": "PROD-DB", + "Folder": "Root", + "Name": "PROD-DB-svc_account", + "PlatformID": "Oracle", + "PasswordChangeInProcess": "False", + } + + +def _error_response(code: str, msg: str) -> dict: + return {"ErrorCode": code, "ErrorMsg": msg} + + +class _MockTransport(httpx.AsyncBaseTransport): + """Simple mock transport that returns a pre-set response.""" + + def __init__(self, status_code: int, body: dict) -> None: + self._status = status_code + self._body = body + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response( + self._status, + headers={"content-type": "application/json"}, + content=json.dumps(self._body).encode(), + request=request, + ) + + +def _client_with_transport(transport: httpx.AsyncBaseTransport) -> CyberArkCCPClient: + """Create a CyberArkCCPClient with a mock transport pre-injected.""" + client = CyberArkCCPClient() + client._http = httpx.AsyncClient(transport=transport) + return client + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_get_credential_success() -> None: + transport = _MockTransport(200, _ok_response()) + client = _client_with_transport(transport) + + cred = await client.get_credential( + app_id="MyApp", safe="PROD-DB", object_name="PROD-DB-svc_account" + ) + + assert isinstance(cred, Credential) + assert cred.username == "svc_account" + assert cred.password == "S3cr3tP@ss" + assert cred.address == "db.internal" + assert cred.platform_id == "Oracle" + assert cred.password_change_in_process is False + + +async def test_get_credential_not_found_raises() -> None: + transport = _MockTransport(404, _error_response("APPAP007E", "Credential object not found")) + client = _client_with_transport(transport) + + with pytest.raises(CyberArkError) as exc_info: + await client.get_credential(app_id="MyApp", safe="PROD-DB", object_name="missing") + + assert exc_info.value.error_code == "APPAP007E" + assert exc_info.value.status_code == 404 + + +async def test_get_credential_auth_failure_raises() -> None: + transport = _MockTransport(403, _error_response("APPAP006E", "Authentication failure")) + client = _client_with_transport(transport) + + with pytest.raises(CyberArkError) as exc_info: + await client.get_credential(app_id="BadApp", safe="PROD-DB", object_name="obj") + + assert exc_info.value.error_code == "APPAP006E" + assert exc_info.value.status_code == 403 + + +async def test_get_credential_unknown_error_code() -> None: + """Unknown error codes should still raise CyberArkError with the raw message.""" + transport = _MockTransport(500, _error_response("ZZZZZ999E", "Unexpected internal error")) + client = _client_with_transport(transport) + + with pytest.raises(CyberArkError) as exc_info: + await client.get_credential(app_id="MyApp", safe="S", object_name="O") + + assert "Unexpected internal error" in str(exc_info.value) + + +async def test_get_credential_non_json_body() -> None: + """Non-JSON 500 responses should still raise a CyberArkError.""" + class _HtmlTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(500, content=b"Internal Server Error", request=request) + + client = _client_with_transport(_HtmlTransport()) + with pytest.raises(CyberArkError) as exc_info: + await client.get_credential(app_id="MyApp", safe="S", object_name="O") + assert exc_info.value.status_code == 500 + + +async def test_connect_error_raises() -> None: + class _FailTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("Connection refused") + + client = _client_with_transport(_FailTransport()) + with pytest.raises(CyberArkError, match="Cannot reach CCP"): + await client.get_credential(app_id="MyApp", safe="S", object_name="O") + + +async def test_timeout_raises() -> None: + class _TimeoutTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("Timed out") + + client = _client_with_transport(_TimeoutTransport()) + with pytest.raises(CyberArkError, match="timed out"): + await client.get_credential(app_id="MyApp", safe="S", object_name="O") + + +async def test_assert_started_raises_if_not_started() -> None: + client = CyberArkCCPClient() + with pytest.raises(RuntimeError, match="not been started"): + await client.get_credential(app_id="A", safe="S", object_name="O") + + +async def test_list_safes_raises_not_implemented() -> None: + client = _client_with_transport(_MockTransport(200, {})) + with pytest.raises(NotImplementedError): + await client.list_safes("MyApp") + + +async def test_password_not_in_error_message() -> None: + """Ensure passwords are never leaked into exception messages.""" + transport = _MockTransport(200, _ok_response(password="SuperSecret123")) + client = _client_with_transport(transport) + cred = await client.get_credential(app_id="A", safe="S", object_name="O") + assert cred.password == "SuperSecret123" + # The Credential dataclass itself is fine, but error paths must not include it + # (no error raised here — just confirming the happy path returns it correctly + # and the password doesn't appear in repr of the transport or request) diff --git a/tests/test_database_server.py b/tests/test_database_server.py new file mode 100644 index 0000000..2d96615 --- /dev/null +++ b/tests/test_database_server.py @@ -0,0 +1,303 @@ +""" +Tests for the Database MCP tool (db_query). + +We patch _dispatch_query (the internal router) rather than individual drivers +so the tests stay driver-agnostic. Driver-specific tests (asyncpg / aiomysql / +pyodbc) are covered in the integration section at the bottom. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from mcp_privileged.config import settings +from mcp_privileged.database.server import ( + _cell_str, + _format_result, + db_query, +) +from mcp_privileged.secret_store import secret_store +from tests.conftest import make_db_result + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +async def _handle(username: str = "db_svc", password: str = "DbP@ss!") -> str: + return await secret_store.store(username, password) + + +def _patch_dispatch(columns: list[str], rows: list[list]): + """Patch _dispatch_query to return a pre-built result without hitting a DB.""" + return patch( + "mcp_privileged.database.server._dispatch_query", + new=AsyncMock(return_value=make_db_result(columns, rows)), + ) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_db_query_success_postgres(mock_ctx) -> None: + """Happy path: postgres query returns columns + rows.""" + handle = await _handle() + cols = ["id", "name", "email"] + rows = [[1, "Alice", "alice@example.com"], [2, "Bob", "bob@example.com"]] + + with _patch_dispatch(cols, rows): + result = await db_query( + host="pg.internal", + database="mydb", + query="SELECT id, name, email FROM users", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + assert "Rows returned: 2" in result + assert "id" in result and "name" in result and "email" in result + assert "Alice" in result + assert "Database: mydb (postgres)" in result + + +async def test_db_query_success_mysql(mock_ctx) -> None: + """MySQL variant — db_type routing and label are correct.""" + handle = await _handle() + + with _patch_dispatch(["host_name"], [["mysql-server-01"]]): + result = await db_query( + host="mysql.internal", + database="ops", + query="SELECT @@hostname", + secret_handle=handle, + ctx=mock_ctx, + db_type="mysql", + ) + + assert "mysql" in result + assert "mysql-server-01" in result + + +async def test_db_query_success_mssql(mock_ctx) -> None: + """SQL Server variant — db_type routing and label are correct.""" + handle = await _handle() + + with _patch_dispatch(["name"], [["SQLSERVER01"]]): + result = await db_query( + host="sql.internal", + database="master", + query="SELECT @@SERVERNAME", + secret_handle=handle, + ctx=mock_ctx, + db_type="mssql", + ) + + assert "mssql" in result + assert "SQLSERVER01" in result + + +async def test_db_query_default_port_resolved(mock_ctx) -> None: + """port=0 triggers the default port for the db_type.""" + handle = await _handle() + + with _patch_dispatch(["v"], [[42]]) as mock_dispatch: + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 42", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + port=0, + ) + + _, kwargs = mock_dispatch.call_args + assert kwargs["port"] == 5432 + + +async def test_db_query_custom_port_forwarded(mock_ctx) -> None: + """Explicit port is forwarded unchanged.""" + handle = await _handle() + + with _patch_dispatch(["v"], [[1]]) as mock_dispatch: + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + port=15432, + ) + + _, kwargs = mock_dispatch.call_args + assert kwargs["port"] == 15432 + + +async def test_db_query_username_override(mock_ctx) -> None: + """username_override replaces the credential username.""" + handle = await _handle(username="readonly_user") + + with _patch_dispatch(["v"], [[1]]) as mock_dispatch: + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + username_override="dba_user", + ) + + _, kwargs = mock_dispatch.call_args + assert kwargs["username"] == "dba_user" + + +async def test_db_query_invalid_db_type(mock_ctx) -> None: + """Unknown db_type raises ValueError before touching the credential store.""" + handle = await _handle() + + with pytest.raises(ValueError, match="Unsupported db_type"): + await db_query( + host="db.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="oracle", + ) + + +async def test_db_query_invalid_handle(mock_ctx) -> None: + """Unknown handle raises KeyError and calls ctx.error.""" + with pytest.raises(KeyError): + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle="secret://doesnotexist0000000000000000", + ctx=mock_ctx, + db_type="postgres", + ) + + mock_ctx.error.assert_awaited_once() + + +async def test_db_query_driver_exception_propagates(mock_ctx) -> None: + """Exceptions from _dispatch_query propagate and call ctx.error.""" + handle = await _handle() + + with patch( + "mcp_privileged.database.server._dispatch_query", + new=AsyncMock(side_effect=ConnectionRefusedError("DB port closed")), + ): + with pytest.raises(ConnectionRefusedError): + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + mock_ctx.error.assert_awaited_once() + + +async def test_db_query_rows_capped(mock_ctx) -> None: + """Rows exceeding db_max_rows are truncated and the result says so.""" + handle = await _handle() + many_rows = [[i, f"user_{i}"] for i in range(2000)] + + with _patch_dispatch(["id", "name"], many_rows): + with patch.object(settings, "db_max_rows", 10): + result = await db_query( + host="pg.internal", + database="mydb", + query="SELECT id, name FROM big_table", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + assert "Rows returned: 10" in result + assert "more rows exist" in result + + +async def test_db_query_empty_result(mock_ctx) -> None: + """An empty result set is handled gracefully.""" + handle = await _handle() + + with _patch_dispatch([], []): + result = await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1 WHERE false", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + assert "Rows returned: 0" in result + assert "No rows returned" in result + + +async def test_db_query_password_not_in_ctx_messages(mock_ctx) -> None: + """The credential password must never leak into ctx.info or ctx.error.""" + secret_password = "DB$ecretPass99" + handle = await secret_store.store("db_user", secret_password) + + with _patch_dispatch(["v"], [[1]]): + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + all_calls = mock_ctx.info.await_args_list + mock_ctx.error.await_args_list + for call in all_calls: + assert secret_password not in str(call) + + +# ── Unit tests for helpers ──────────────────────────────────────────────────── + +def test_cell_str_none() -> None: + assert _cell_str(None) == "" + + +def test_cell_str_normal() -> None: + assert _cell_str(42) == "42" + assert _cell_str("hello") == "hello" + + +def test_cell_str_truncated() -> None: + long_val = "x" * 10_000 + with patch.object(settings, "db_max_cell_bytes", 10): + result = _cell_str(long_val) + assert "…" in result + assert len(result) < 20 + + +def test_format_result_no_rows() -> None: + result = _format_result("host", "db", "postgres", "SELECT 1", [], [], False, 5.0) + assert "No rows returned" in result + + +def test_format_result_with_rows() -> None: + cols = ["id", "name"] + rows = [[1, "Alice"], [2, "Bob"]] + result = _format_result("host", "db", "postgres", "SELECT ...", cols, rows, False, 12.3) + assert "id" in result + assert "Alice" in result + assert "Bob" in result + assert "Rows returned: 2" in result + + +def test_format_result_truncated_flag() -> None: + cols = ["id"] + rows = [[i] for i in range(5)] + result = _format_result("host", "db", "postgres", "SELECT ...", cols, rows, True, 1.0) + assert "capped" in result diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..6e92381 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,330 @@ +""" +Integration tests — end-to-end flows across multiple MCP tools. + +These tests verify that the FULL PIPELINE works: + CyberArk MCP → (handle) → SSH / PowerShell / DB MCP + +They also serve as a learning resource for how MCP tools compose: + + ┌─────────────────────────────────────────────────────────────────┐ + │ Claude (LLM) │ + │ 1. Calls get_credential(safe, object_name) │ + │ → receives "secret://abc123..." (handle only) │ + │ 2. Calls ssh_execute(host, command, secret_handle=handle) │ + │ → receives command output │ + └─────────────────────────────────────────────────────────────────┘ + +At no point does Claude see the actual password. +The handle is an opaque token that binds a short TTL credential to one use. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcp_privileged.cyberark.client import CyberArkCCPClient, Credential +from mcp_privileged.cyberark.server import get_credential +from mcp_privileged.database.server import db_query +from mcp_privileged.powershell.server import ps_execute +from mcp_privileged.secret_store import secret_store +from mcp_privileged.ssh.server import ssh_execute +from tests.conftest import make_db_result, make_ps_result, make_ssh_cm + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _make_ctx(client_ip: str = "10.0.0.1") -> MagicMock: + ctx = MagicMock() + ctx.info = AsyncMock() + ctx.error = AsyncMock() + ctx.request_context.request.headers = {"X-Forwarded-For": client_ip} + ctx.request_context.request.client = None + return ctx + + +def _mock_cyberark_client(username: str, password: str, address: str = "db.internal"): + """Patch the CyberArk CCP client to return a fixed credential.""" + cred = Credential( + username=username, + password=password, + address=address, + safe="PROD-SAFE", + folder="Root", + object_name="PROD-DB-svc", + platform_id="UnixSSH", + password_change_in_process=False, + ) + mock_client = MagicMock(spec=CyberArkCCPClient) + mock_client.get_credential = AsyncMock(return_value=cred) + mock_client._settings_app_id = lambda: "MCP-Privileged-Service" + return patch("mcp_privileged.cyberark.server.cyberark_client", mock_client) + + +# ── Full pipeline: CyberArk → SSH ───────────────────────────────────────────── + +async def test_cyberark_to_ssh_full_pipeline() -> None: + """ + Simulate the complete CyberArk → SSH pipeline: + + 1. get_credential() fetches from CyberArk, stores in secret_store, returns handle. + 2. ssh_execute() resolves the handle, uses the password to connect, returns output. + 3. The password never appears in either tool's return value. + + This is the primary privileged-access use case: + Claude: "Run `df -h` on linux01 using the PROD-LINUX credential" + """ + ctx_cyberark = _make_ctx("192.168.1.10") + ctx_ssh = _make_ctx("192.168.1.10") + + # Step 1: Claude calls get_credential + with _mock_cyberark_client(username="root", password="SshSecret!"): + handle_response = await get_credential( + safe="PROD-SAFE", + object_name="PROD-LINUX-root", + ctx=ctx_cyberark, + ) + + # The LLM receives a handle string — NOT the password + assert "secret://" in handle_response + assert "SshSecret!" not in handle_response + + # Extract the handle token from the formatted response text + handle = next( + line.split("Handle: ")[1] + for line in handle_response.splitlines() + if line.startswith("Handle: ") + ) + + # Step 2: Claude calls ssh_execute with the handle + mock_cm, _ = make_ssh_cm(stdout="/dev/sda1 50G 10G 40G 20% /\n", exit_status=0) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + ssh_result = await ssh_execute( + host="linux01.internal", + command="df -h", + secret_handle=handle, + ctx=ctx_ssh, + ) + + assert "Exit code: 0" in ssh_result + assert "/dev/sda1" in ssh_result + assert "SshSecret!" not in ssh_result + + +async def test_cyberark_to_powershell_full_pipeline() -> None: + """Simulate CyberArk → PowerShell pipeline.""" + ctx_ca = _make_ctx() + ctx_ps = _make_ctx() + + with _mock_cyberark_client(username="domain\\svc_ps", password="WinSecret!"): + handle_response = await get_credential( + safe="WIN-SAFE", + object_name="WIN-svc_ps", + ctx=ctx_ca, + ) + + assert "WinSecret!" not in handle_response + handle = next( + line.split("Handle: ")[1] + for line in handle_response.splitlines() + if line.startswith("Handle: ") + ) + + ps_result = make_ps_result(output=["WIN-SERVER-01"], had_errors=False) + + with patch("mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result): + ps_out = await ps_execute( + host="win01.internal", + script="hostname", + secret_handle=handle, + ctx=ctx_ps, + ) + + assert "Had errors: False" in ps_out + assert "WIN-SERVER-01" in ps_out + assert "WinSecret!" not in ps_out + + +async def test_cyberark_to_database_full_pipeline() -> None: + """Simulate CyberArk → Database pipeline.""" + ctx_ca = _make_ctx() + ctx_db = _make_ctx() + + with _mock_cyberark_client(username="db_reader", password="DbSecret!"): + handle_response = await get_credential( + safe="DB-SAFE", + object_name="PROD-PG-reader", + ctx=ctx_ca, + ) + + assert "DbSecret!" not in handle_response + handle = next( + line.split("Handle: ")[1] + for line in handle_response.splitlines() + if line.startswith("Handle: ") + ) + + with patch( + "mcp_privileged.database.server._dispatch_query", + new=AsyncMock(return_value=make_db_result(["count"], [[42]])), + ): + db_out = await db_query( + host="pg.internal", + database="prod", + query="SELECT COUNT(*) FROM users", + secret_handle=handle, + ctx=ctx_db, + db_type="postgres", + ) + + assert "42" in db_out + assert "DbSecret!" not in db_out + + +# ── Handle lifecycle ────────────────────────────────────────────────────────── + +async def test_handle_single_use_enforced() -> None: + """ + A handle issued by get_credential can only be resolved ONCE + (when handle_single_use=True, which is the default). + + This prevents credential replay attacks: + if an attacker intercepts the handle, it's already been consumed. + """ + ctx = _make_ctx() + mock_cm, _ = make_ssh_cm(stdout="ok\n", exit_status=0) + + with _mock_cyberark_client(username="user", password="pass"): + handle_response = await get_credential( + safe="S", object_name="O", ctx=ctx + ) + + handle = next( + line.split("Handle: ")[1] + for line in handle_response.splitlines() + if line.startswith("Handle: ") + ) + + # First use — succeeds + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + await ssh_execute( + host="host1", command="id", secret_handle=handle, ctx=ctx + ) + + # Second use — same handle, should fail + with pytest.raises(KeyError, match="consumed|not found"): + await ssh_execute( + host="host1", command="id", secret_handle=handle, ctx=ctx + ) + + +async def test_handle_cannot_be_shared_across_tools() -> None: + """ + A handle resolved by ssh_execute cannot then be reused by db_query. + One credential fetch = one privileged operation. + """ + ctx = _make_ctx() + mock_cm, _ = make_ssh_cm(stdout="ok\n", exit_status=0) + + # Issue one handle + handle = await secret_store.store("user", "pass") + + # SSH consumes it + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + await ssh_execute( + host="host1", command="id", secret_handle=handle, ctx=ctx + ) + + # DB tries to reuse it — must fail + with pytest.raises(KeyError): + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=ctx, + db_type="postgres", + ) + + +async def test_expired_handle_rejected() -> None: + """ + A handle past its TTL is rejected even if not yet consumed. + We simulate expiry by manually backdating the entry's created_at. + """ + import time + + handle = await secret_store.store("user", "pass") + handle_id = handle.split("://")[1] + + # Backdate the entry so it looks expired + async with secret_store._lock: + entry = secret_store._store[handle_id] + entry.created_at = time.monotonic() - 99999 # very old + + with pytest.raises(KeyError, match="expired"): + await secret_store.resolve(handle, resolved_by="test") + + +# ── Concurrent handle isolation ─────────────────────────────────────────────── + +async def test_concurrent_handles_are_independent() -> None: + """ + Multiple handles issued at the same time are independent. + Resolving one does not affect the others. + """ + handles = [await secret_store.store(f"user_{i}", f"pass_{i}") for i in range(5)] + + # Resolve them in reverse order + results = [] + for handle in reversed(handles): + username, password = await secret_store.resolve(handle, resolved_by="test") + results.append((username, password)) + + assert len(results) == 5 + # Each (username, password) pair is unique + assert len(set(results)) == 5 + + +# ── Audit trail ─────────────────────────────────────────────────────────────── + +async def test_audit_events_fired_for_ssh(mock_ctx) -> None: + """ + ssh_execute must call ctx.info() at least twice: + once for connection start, once for completion. + ctx.error must NOT be called on the happy path. + """ + handle = await secret_store.store("user", "pass") + mock_cm, _ = make_ssh_cm(stdout="ok\n", exit_status=0) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + await ssh_execute( + host="host1", command="id", secret_handle=handle, ctx=mock_ctx + ) + + assert mock_ctx.info.await_count >= 2 + mock_ctx.error.assert_not_awaited() + + +async def test_audit_events_fired_for_db(mock_ctx) -> None: + """db_query must emit ctx.info on the happy path, not ctx.error.""" + handle = await secret_store.store("user", "pass") + + with patch( + "mcp_privileged.database.server._dispatch_query", + new=AsyncMock(return_value=make_db_result(["v"], [[1]])), + ): + await db_query( + host="pg.internal", + database="mydb", + query="SELECT 1", + secret_handle=handle, + ctx=mock_ctx, + db_type="postgres", + ) + + assert mock_ctx.info.await_count >= 2 + mock_ctx.error.assert_not_awaited() diff --git a/tests/test_powershell_server.py b/tests/test_powershell_server.py new file mode 100644 index 0000000..bd0b64e --- /dev/null +++ b/tests/test_powershell_server.py @@ -0,0 +1,227 @@ +""" +Tests for the PowerShell MCP tool (ps_execute). + +pypsrp is a synchronous library. The server wraps _run_ps_sync() in +asyncio.run_in_executor so we patch _run_ps_sync directly — no real WinRM +connections are made. +""" + +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +import pytest + +from mcp_privileged.powershell.server import _format_result, _truncate, ps_execute +from mcp_privileged.secret_store import secret_store +from tests.conftest import make_ps_result + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +async def _handle(username: str = "svc_user", password: str = "P@ss!") -> str: + return await secret_store.store(username, password) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_ps_execute_success(mock_ctx) -> None: + """Happy path: script runs, output is returned, had_errors=False.""" + handle = await _handle() + ps_result = make_ps_result(output=["Win2022", "Server"], had_errors=False) + + with patch("mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result): + result = await ps_execute( + host="win01.internal", + script="$PSVersionTable.OS; hostname", + secret_handle=handle, + ctx=mock_ctx, + ) + + assert "Had errors: False" in result + assert "Win2022" in result + assert "Server" in result + assert "Host: win01.internal" in result + + +async def test_ps_execute_with_errors(mock_ctx) -> None: + """Script produces errors — had_errors=True and error records are included.""" + handle = await _handle() + ps_result = make_ps_result( + output=[], + had_errors=True, + errors=["Get-Item : Cannot find path 'C:\\missing'"], + ) + + with patch("mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result): + result = await ps_execute( + host="win01.internal", + script="Get-Item C:\\missing", + secret_handle=handle, + ctx=mock_ctx, + ) + + assert "Had errors: True" in result + assert "Cannot find path" in result + assert "--- errors ---" in result + + +async def test_ps_execute_no_output(mock_ctx) -> None: + """Script runs but produces no output (e.g. Set-* cmdlets).""" + handle = await _handle() + ps_result = make_ps_result(output=[], had_errors=False) + + with patch("mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result): + result = await ps_execute( + host="win01.internal", + script="Set-TimeZone -Id 'UTC'", + secret_handle=handle, + ctx=mock_ctx, + ) + + assert "Had errors: False" in result + assert "(no output)" in result + + +async def test_ps_execute_username_override(mock_ctx) -> None: + """username_override is forwarded to _run_ps_sync.""" + handle = await _handle(username="domain\\original") + ps_result = make_ps_result(output=["ok"]) + + with patch( + "mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result + ) as mock_run: + await ps_execute( + host="win01.internal", + script="whoami", + secret_handle=handle, + ctx=mock_ctx, + username_override="domain\\admin", + ) + + # Third positional arg to _run_ps_sync is username + _args, _ = mock_run.call_args + assert _args[2] == "domain\\admin" + + +async def test_ps_execute_credential_username_used_by_default(mock_ctx) -> None: + """Without username_override, the credential username is forwarded.""" + handle = await _handle(username="domain\\svc_ps") + ps_result = make_ps_result(output=["ok"]) + + with patch( + "mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result + ) as mock_run: + await ps_execute( + host="win01.internal", + script="whoami", + secret_handle=handle, + ctx=mock_ctx, + ) + + _args, _ = mock_run.call_args + assert _args[2] == "domain\\svc_ps" + + +async def test_ps_execute_invalid_handle(mock_ctx) -> None: + """Unknown handle raises KeyError before any WinRM connection is attempted.""" + with pytest.raises(KeyError): + await ps_execute( + host="win01.internal", + script="hostname", + secret_handle="secret://doesnotexist0000000000000000", + ctx=mock_ctx, + ) + + mock_ctx.error.assert_awaited_once() + + +async def test_ps_execute_winrm_exception_propagates(mock_ctx) -> None: + """Exceptions from _run_ps_sync propagate and call ctx.error.""" + handle = await _handle() + + with patch( + "mcp_privileged.powershell.server._run_ps_sync", + side_effect=ConnectionRefusedError("WinRM port closed"), + ): + with pytest.raises(ConnectionRefusedError): + await ps_execute( + host="dead.host", + script="hostname", + secret_handle=handle, + ctx=mock_ctx, + ) + + mock_ctx.error.assert_awaited_once() + + +async def test_ps_execute_password_not_in_ctx_messages(mock_ctx) -> None: + """The password must never appear in any ctx.info or ctx.error call.""" + secret_password = "WinRM$ecret99" + handle = await secret_store.store("user", secret_password) + ps_result = make_ps_result(output=["ok"]) + + with patch("mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result): + await ps_execute( + host="win01.internal", + script="hostname", + secret_handle=handle, + ctx=mock_ctx, + ) + + all_calls = mock_ctx.info.await_args_list + mock_ctx.error.await_args_list + for call in all_calls: + assert secret_password not in str(call), "Password leaked into MCP context log" + + +async def test_ps_execute_ssl_and_port_forwarded(mock_ctx) -> None: + """use_ssl=True and custom port are forwarded to _run_ps_sync.""" + handle = await _handle() + ps_result = make_ps_result(output=["ok"]) + + with patch( + "mcp_privileged.powershell.server._run_ps_sync", return_value=ps_result + ) as mock_run: + await ps_execute( + host="win01.internal", + script="hostname", + secret_handle=handle, + ctx=mock_ctx, + port=5986, + use_ssl=True, + ) + + _args, _ = mock_run.call_args + assert _args[1] == 5986 # port + assert _args[5] is True # use_ssl + + +# ── Unit tests for helpers ───────────────────────────────────────────────────── + +def test_truncate_passthrough() -> None: + assert _truncate("hello", 1024, "output") == "hello" + + +def test_truncate_applies_limit() -> None: + result = _truncate("x" * 10_000, 100, "output") + assert "truncated" in result + assert len(result.encode()) < 300 + + +def test_format_result_no_errors() -> None: + result = _format_result("win01", "Get-Process", False, ["proc1", "proc2"], []) + assert "Had errors: False" in result + assert "proc1" in result + assert "--- errors ---" not in result + + +def test_format_result_with_errors() -> None: + result = _format_result("win01", "bad_cmd", True, [], ["Error: not found"]) + assert "Had errors: True" in result + assert "--- errors ---" in result + assert "not found" in result + + +def test_format_result_empty_output() -> None: + result = _format_result("win01", "Set-X", False, [], []) + assert "(no output)" in result diff --git a/tests/test_secret_store.py b/tests/test_secret_store.py new file mode 100644 index 0000000..569737e --- /dev/null +++ b/tests/test_secret_store.py @@ -0,0 +1,100 @@ +""" +Tests for the secret handle store. +Covers: store, resolve, single-use, TTL expiry, revoke, and sweeper. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from mcp_privileged.secret_store import SecretStore, HANDLE_PREFIX + + +@pytest.fixture +def store() -> SecretStore: + return SecretStore() + + +async def test_store_returns_handle(store: SecretStore) -> None: + handle = await store.store("user1", "s3cr3t") + assert handle.startswith(HANDLE_PREFIX) + + +async def test_resolve_returns_credentials(store: SecretStore) -> None: + handle = await store.store("user1", "s3cr3t") + username, password = await store.resolve(handle, resolved_by="test") + assert username == "user1" + assert password == "s3cr3t" + + +async def test_single_use_invalidates_after_first_resolve( + store: SecretStore, monkeypatch +) -> None: + monkeypatch.setattr("mcp_privileged.secret_store.settings.handle_single_use", True) + handle = await store.store("user1", "s3cr3t") + await store.resolve(handle, resolved_by="test") + with pytest.raises(KeyError, match="already_consumed|not found"): + await store.resolve(handle, resolved_by="test") + + +async def test_multi_use_allows_repeated_resolve( + store: SecretStore, monkeypatch +) -> None: + monkeypatch.setattr("mcp_privileged.secret_store.settings.handle_single_use", False) + handle = await store.store("user1", "s3cr3t") + for _ in range(3): + username, password = await store.resolve(handle, resolved_by="test") + assert password == "s3cr3t" + + +async def test_expired_handle_raises(store: SecretStore, monkeypatch) -> None: + monkeypatch.setattr("mcp_privileged.secret_store.settings.handle_ttl_seconds", 1) + handle = await store.store("user1", "s3cr3t") + # Manually backdate the entry's creation time + handle_id = handle[len(HANDLE_PREFIX):] + store._store[handle_id].created_at = time.monotonic() - 5 + with pytest.raises(KeyError, match="expired"): + await store.resolve(handle, resolved_by="test") + + +async def test_unknown_handle_raises(store: SecretStore) -> None: + with pytest.raises(KeyError): + await store.resolve(f"{HANDLE_PREFIX}nonexistent", resolved_by="test") + + +async def test_invalid_handle_format_raises(store: SecretStore) -> None: + with pytest.raises(ValueError, match="Invalid handle format"): + await store.resolve("not-a-handle", resolved_by="test") + + +async def test_revoke_removes_handle(store: SecretStore) -> None: + handle = await store.store("user1", "s3cr3t") + assert await store.revoke(handle) is True + with pytest.raises(KeyError): + await store.resolve(handle, resolved_by="test") + + +async def test_revoke_nonexistent_returns_false(store: SecretStore) -> None: + assert await store.revoke(f"{HANDLE_PREFIX}nonexistent") is False + + +async def test_purge_expired_removes_stale(store: SecretStore, monkeypatch) -> None: + monkeypatch.setattr("mcp_privileged.secret_store.settings.handle_ttl_seconds", 1) + handle = await store.store("user1", "s3cr3t") + handle_id = handle[len(HANDLE_PREFIX):] + store._store[handle_id].created_at = time.monotonic() - 5 + count = await store.purge_expired() + assert count == 1 + assert handle_id not in store._store + + +async def test_password_not_in_repr(store: SecretStore) -> None: + """SecretStr must not leak the password in string representations.""" + handle = await store.store("user1", "topsecret") + handle_id = handle[len(HANDLE_PREFIX):] + entry = store._store[handle_id] + assert "topsecret" not in repr(entry) + assert "topsecret" not in str(entry.password) diff --git a/tests/test_ssh_server.py b/tests/test_ssh_server.py new file mode 100644 index 0000000..ab426a7 --- /dev/null +++ b/tests/test_ssh_server.py @@ -0,0 +1,291 @@ +""" +Tests for the SSH MCP tool (ssh_execute). + +All tests mock asyncssh.connect — no real SSH connections are made. +The secret_store is used directly so handle issuance/resolution is tested +end-to-end through the real store. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import asyncssh +import pytest + +from mcp_privileged.secret_store import secret_store +from mcp_privileged.ssh.server import _truncate, _format_result, ssh_execute + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _make_ctx() -> MagicMock: + """Return a minimal mock MCP Context.""" + ctx = MagicMock() + ctx.info = AsyncMock() + ctx.error = AsyncMock() + # _extract_client_ip uses these + ctx.request_context.request.headers = {} + ctx.request_context.request.client = None + return ctx + + +def _make_ssh_cm( + stdout: str = "", + stderr: str = "", + exit_status: int = 0, +) -> tuple[AsyncMock, AsyncMock]: + """ + Build a mock for asyncssh.connect used as an async context manager. + + Returns (context_manager_mock, conn_mock). + Patch asyncssh.connect with return_value=context_manager_mock. + """ + mock_conn = AsyncMock() + mock_conn.run = AsyncMock( + return_value=MagicMock(stdout=stdout, stderr=stderr, exit_status=exit_status) + ) + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=False) + return mock_cm, mock_conn + + +async def _fresh_handle(username: str = "svc_user", password: str = "P@ssw0rd!") -> str: + """Store a credential and return a fresh (unconsumed) handle.""" + return await secret_store.store(username, password) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_ssh_execute_success() -> None: + """Happy path: command runs, stdout is returned, exit code is 0.""" + handle = await _fresh_handle() + ctx = _make_ctx() + mock_cm, _ = _make_ssh_cm(stdout="hello world\n", exit_status=0) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + result = await ssh_execute( + host="linux01.internal", + command="echo hello world", + secret_handle=handle, + ctx=ctx, + ) + + assert "Exit code: 0" in result + assert "hello world" in result + assert "Host: linux01.internal" in result + assert "Command: echo hello world" in result + + +async def test_ssh_execute_nonzero_exit_not_raised() -> None: + """A non-zero exit code is returned in the result, not raised as an exception.""" + handle = await _fresh_handle() + ctx = _make_ctx() + mock_cm, _ = _make_ssh_cm(stdout="", stderr="command not found\n", exit_status=127) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + result = await ssh_execute( + host="linux01.internal", + command="notacommand", + secret_handle=handle, + ctx=ctx, + ) + + assert "Exit code: 127" in result + assert "command not found" in result + + +async def test_ssh_execute_stderr_included() -> None: + """Both stdout and stderr appear in the result when both are non-empty.""" + handle = await _fresh_handle() + ctx = _make_ctx() + mock_cm, _ = _make_ssh_cm(stdout="result\n", stderr="warning: low disk\n", exit_status=0) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + result = await ssh_execute( + host="host1", + command="df -h", + secret_handle=handle, + ctx=ctx, + ) + + assert "result" in result + assert "warning: low disk" in result + + +async def test_ssh_execute_username_override() -> None: + """username_override replaces the credential's username in the connect call.""" + handle = await _fresh_handle(username="original_user") + ctx = _make_ctx() + mock_cm, _ = _make_ssh_cm(stdout="uid=0(root)\n", exit_status=0) + + with patch( + "mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm + ) as mock_connect: + await ssh_execute( + host="host1", + command="id", + secret_handle=handle, + ctx=ctx, + username_override="root", + ) + + _args, _kwargs = mock_connect.call_args + assert _kwargs["username"] == "root" + + +async def test_ssh_execute_credential_username_used_by_default() -> None: + """Without username_override, the credential's username is passed to connect.""" + handle = await _fresh_handle(username="db_admin") + ctx = _make_ctx() + mock_cm, _ = _make_ssh_cm(stdout="ok\n", exit_status=0) + + with patch( + "mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm + ) as mock_connect: + await ssh_execute( + host="host1", + command="whoami", + secret_handle=handle, + ctx=ctx, + ) + + _args, _kwargs = mock_connect.call_args + assert _kwargs["username"] == "db_admin" + + +async def test_ssh_execute_invalid_handle_raises() -> None: + """An unknown handle raises KeyError and calls ctx.error.""" + ctx = _make_ctx() + + with pytest.raises(KeyError): + await ssh_execute( + host="host1", + command="id", + secret_handle="secret://doesnotexist0000000000000000", + ctx=ctx, + ) + + ctx.error.assert_awaited_once() + + +async def test_ssh_execute_connect_os_error_propagates() -> None: + """An OSError (e.g. connection refused) propagates and calls ctx.error.""" + handle = await _fresh_handle() + ctx = _make_ctx() + + with patch( + "mcp_privileged.ssh.server.asyncssh.connect", + side_effect=OSError("Connection refused"), + ): + with pytest.raises(OSError): + await ssh_execute( + host="dead.host", + command="id", + secret_handle=handle, + ctx=ctx, + ) + + ctx.error.assert_awaited_once() + + +async def test_ssh_execute_permission_denied_propagates() -> None: + """asyncssh.PermissionDenied propagates and calls ctx.error.""" + handle = await _fresh_handle() + ctx = _make_ctx() + + with patch( + "mcp_privileged.ssh.server.asyncssh.connect", + side_effect=asyncssh.PermissionDenied("Permission denied"), + ): + with pytest.raises(asyncssh.PermissionDenied): + await ssh_execute( + host="host1", + command="id", + secret_handle=handle, + ctx=ctx, + ) + + ctx.error.assert_awaited_once() + + +async def test_ssh_execute_command_timeout_propagates() -> None: + """asyncio.TimeoutError from conn.run propagates and calls ctx.error.""" + handle = await _fresh_handle() + ctx = _make_ctx() + + mock_conn = AsyncMock() + mock_conn.run = AsyncMock(side_effect=asyncio.TimeoutError()) + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + with pytest.raises(asyncio.TimeoutError): + await ssh_execute( + host="slow.host", + command="sleep 999", + secret_handle=handle, + ctx=ctx, + timeout_seconds=1, + ) + + ctx.error.assert_awaited_once() + + +async def test_ssh_execute_password_not_in_result() -> None: + """The credential password must never appear in the tool's return value.""" + secret_password = "SuperSecret!123" + handle = await _fresh_handle(password=secret_password) + ctx = _make_ctx() + # Simulate a misconfigured command that echoes env vars containing the password + mock_cm, _ = _make_ssh_cm(stdout=f"PASSWORD={secret_password}\n", exit_status=0) + + with patch("mcp_privileged.ssh.server.asyncssh.connect", return_value=mock_cm): + result = await ssh_execute( + host="host1", + command="env", + secret_handle=handle, + ctx=ctx, + ) + + # The password leaking from stdout is the application's problem, not ours — + # what we must guarantee is that the *handle resolution* never injects it. + # Verify it doesn't appear in any ctx.error/ctx.info call from our code: + for call in ctx.error.await_args_list + ctx.info.await_args_list: + assert secret_password not in str(call), "Password leaked into MCP context log" + + +# ── Unit tests for helpers ──────────────────────────────────────────────────── + +def test_truncate_short_text_unchanged() -> None: + text = "hello world" + assert _truncate(text, 1024, "stdout") == text + + +def test_truncate_long_text_truncated() -> None: + text = "x" * 10_000 + result = _truncate(text, 100, "stdout") + assert "truncated" in result + assert len(result.encode("utf-8")) <= 200 # marker adds a short suffix + + +def test_format_result_no_stderr() -> None: + result = _format_result("myhost", "ls /", 0, "bin\nlib\n", "") + assert "--- stderr ---" not in result + assert "Exit code: 0" in result + assert "bin" in result + + +def test_format_result_with_stderr() -> None: + result = _format_result("myhost", "bad_cmd", 1, "", "not found\n") + assert "--- stderr ---" in result + assert "not found" in result + assert "Exit code: 1" in result + + +def test_format_result_empty_stdout_shows_empty_marker() -> None: + result = _format_result("myhost", "true", 0, "", "") + assert "(empty)" in result