diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fded1f8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,61 @@ +# Git +.git +.gitignore +.gitattributes + +# Version control +.github +.gitlab-ci.yml +.circleci + +# Development +.venv +venv +.env +.env.* +!.env.example +*.pyc +__pycache__ +.pytest_cache +.mypy_cache +.ruff_cache +*.egg-info +dist +build +.tox + +# IDE +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Testing +htmlcov +.coverage +.coverage.* +*.cover +.hypothesis + +# Documentation +docs/_build +*.md +!README.md + +# Docker +Dockerfile* +docker-compose.yml +docker-compose.*.yml +.dockerignore +# docker-entrypoint.sh is intentionally included + +# CI/CD +.github/workflows + +# Misc +CONTRIBUTING.md +SECURITY.md +AGENTS.md +CLAUDE.md diff --git a/.github/workflows/docker-startup-tests.yml b/.github/workflows/docker-startup-tests.yml new file mode 100644 index 0000000..fc6c5a4 --- /dev/null +++ b/.github/workflows/docker-startup-tests.yml @@ -0,0 +1,504 @@ +name: Docker Startup + +on: + push: + branches: [main] + paths: + - Dockerfile + - docker-compose.yml + - docker-entrypoint.sh + - pyproject.toml + - src/** + - .github/workflows/docker-startup-tests.yml + pull_request: + branches: [main] + paths: + - Dockerfile + - docker-compose.yml + - docker-entrypoint.sh + - pyproject.toml + - src/** + - .github/workflows/docker-startup-tests.yml + workflow_dispatch: + +concurrency: + group: purple-mcp-docker-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + docker-build: + name: Build Docker Image + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache + tags: purple-mcp:test + load: true + + docker-sse-test: + name: Test SSE Mode + needs: docker-build + # Skip this job for fork PRs since secrets aren't available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Mask secrets + run: | + echo "::add-mask::${{ secrets.CONSOLE_TOKEN }}" + echo "::add-mask::${{ secrets.CONSOLE_BASE_URL }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: purple-mcp:sse-test + load: true + + - name: Test SSE mode startup + run: | + # Start container in background + docker run \ + --rm \ + --detach \ + --name purple-mcp-sse-test \ + -p 8000:8000 \ + -e PURPLEMCP_CONSOLE_TOKEN="${{ secrets.CONSOLE_TOKEN }}" \ + -e PURPLEMCP_CONSOLE_BASE_URL="${{ secrets.CONSOLE_BASE_URL }}" \ + -e MCP_MODE=sse \ + purple-mcp:sse-test + + # Wait for server to start with retry logic + echo "Waiting for SSE server to become healthy..." + for i in {1..10}; do + if docker ps --filter name=purple-mcp-sse-test --format "{{.Names}}" | grep -q purple-mcp-sse-test; then + if curl --retry 3 --retry-delay 2 --retry-connrefused -f http://localhost:8000/health 2>/dev/null; then + echo "✓ SSE mode started successfully and health check passed" + break + fi + else + echo "✗ SSE mode container exited unexpectedly" + docker logs purple-mcp-sse-test + exit 1 + fi + if [ $i -eq 10 ]; then + echo "✗ SSE mode health check failed after retries" + docker logs purple-mcp-sse-test + docker stop purple-mcp-sse-test || true + exit 1 + fi + sleep 2 + done + + # Cleanup + docker stop purple-mcp-sse-test + + docker-streamable-http-test: + name: Test Streamable-HTTP Mode + needs: docker-build + # Skip this job for fork PRs since secrets aren't available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Mask secrets + run: | + echo "::add-mask::${{ secrets.CONSOLE_TOKEN }}" + echo "::add-mask::${{ secrets.CONSOLE_BASE_URL }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: purple-mcp:streamable-http-test + load: true + + - name: Test streamable-http mode startup + run: | + # Start container in background + docker run \ + --rm \ + --detach \ + --name purple-mcp-streamable-http-test \ + -p 8001:8001 \ + -e PURPLEMCP_CONSOLE_TOKEN="${{ secrets.CONSOLE_TOKEN }}" \ + -e PURPLEMCP_CONSOLE_BASE_URL="${{ secrets.CONSOLE_BASE_URL }}" \ + -e MCP_MODE=streamable-http \ + -e MCP_PORT=8001 \ + purple-mcp:streamable-http-test + + # Wait for server to start with retry logic + echo "Waiting for Streamable-HTTP server to become healthy..." + for i in {1..10}; do + if docker ps --filter name=purple-mcp-streamable-http-test --format "{{.Names}}" | grep -q purple-mcp-streamable-http-test; then + if curl --retry 3 --retry-delay 2 --retry-connrefused -f http://localhost:8001/health 2>/dev/null; then + echo "✓ Streamable-HTTP mode started successfully and health check passed" + break + fi + else + echo "✗ Streamable-HTTP mode container exited unexpectedly" + docker logs purple-mcp-streamable-http-test + exit 1 + fi + if [ $i -eq 10 ]; then + echo "✗ Streamable-HTTP mode health check failed after retries" + docker logs purple-mcp-streamable-http-test + docker stop purple-mcp-streamable-http-test || true + exit 1 + fi + sleep 2 + done + + # Cleanup + docker stop purple-mcp-streamable-http-test + + docker-stdio-test: + name: Test STDIO Mode + needs: docker-build + # Skip this job for fork PRs since secrets aren't available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Mask secrets + run: | + echo "::add-mask::${{ secrets.CONSOLE_TOKEN }}" + echo "::add-mask::${{ secrets.CONSOLE_BASE_URL }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: purple-mcp:stdio-test + load: true + + - name: Test STDIO mode startup + run: | + # Send MCP initialize request and wait for response + set +e + (sleep 2 && echo '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "1.0.0"}}}' && sleep 1 && echo '{"jsonrpc": "2.0", "method": "notifications/initialized"}' && sleep 1) | timeout 20 docker run \ + --rm \ + -i \ + -e PURPLEMCP_CONSOLE_TOKEN="${{ secrets.CONSOLE_TOKEN }}" \ + -e PURPLEMCP_CONSOLE_BASE_URL="${{ secrets.CONSOLE_BASE_URL }}" \ + purple-mcp:stdio-test \ + --mode stdio + exit_code=$? + set -e + + # Exit code 124 means timeout (success - server stayed alive) + # Exit code 0 or 141 (SIGPIPE) means server exited cleanly after processing input + if [ $exit_code -eq 0 ] || [ $exit_code -eq 141 ] || [ $exit_code -eq 124 ]; then + echo "✓ STDIO mode started successfully" + exit 0 + else + echo "✗ STDIO mode failed with exit code $exit_code" + exit 1 + fi + + docker-entrypoint-loopback-test: + name: Test Entrypoint Loopback Detection + needs: docker-build + # Skip this job for fork PRs since secrets aren't available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Mask secrets + run: | + echo "::add-mask::${{ secrets.CONSOLE_TOKEN }}" + echo "::add-mask::${{ secrets.CONSOLE_BASE_URL }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: purple-mcp:entrypoint-test + load: true + + - name: Test loopback address detection (127.0.0.1) + run: | + # Replace python with a fake that echoes args - entrypoint execs python so we intercept to see the command it builds + cat > fake-python << 'EOF' + #!/bin/sh + echo "python $@" + EOF + chmod +x fake-python + + # Test with 127.0.0.1 (should NOT have --allow-remote-access) + output=$(docker run --rm \ + -v $(pwd)/fake-python:/app/.venv/bin/python:ro \ + -e MCP_MODE=streamable-http \ + -e MCP_HOST=127.0.0.1 \ + purple-mcp:entrypoint-test) + + echo "$output" + if echo "$output" | grep -q "allow-remote-access"; then + echo "✗ ERROR: 127.0.0.1 should NOT trigger --allow-remote-access" + exit 1 + fi + echo "✓ 127.0.0.1 correctly does NOT trigger remote access" + + - name: Test loopback address detection (::1) + run: | + # Test with ::1 (should NOT have --allow-remote-access) + output=$(docker run --rm \ + -v $(pwd)/fake-python:/app/.venv/bin/python:ro \ + -e MCP_MODE=streamable-http \ + -e MCP_HOST=::1 \ + purple-mcp:entrypoint-test) + + echo "$output" + if echo "$output" | grep -q "allow-remote-access"; then + echo "✗ ERROR: ::1 should NOT trigger --allow-remote-access" + exit 1 + fi + echo "✓ ::1 correctly does NOT trigger remote access" + + - name: Test localhost detection + run: | + # Test with localhost (should NOT have --allow-remote-access) + output=$(docker run --rm \ + -v $(pwd)/fake-python:/app/.venv/bin/python:ro \ + -e MCP_MODE=streamable-http \ + -e MCP_HOST=localhost \ + purple-mcp:entrypoint-test) + + echo "$output" + if echo "$output" | grep -q "allow-remote-access"; then + echo "✗ ERROR: localhost should NOT trigger --allow-remote-access" + exit 1 + fi + echo "✓ localhost correctly does NOT trigger remote access" + + - name: Test non-loopback address detection (0.0.0.0) + run: | + # Test with 0.0.0.0 (SHOULD have --allow-remote-access) + output=$(docker run --rm \ + -v $(pwd)/fake-python:/app/.venv/bin/python:ro \ + -e MCP_MODE=streamable-http \ + -e MCP_HOST=0.0.0.0 \ + purple-mcp:entrypoint-test) + + echo "$output" + if ! echo "$output" | grep -q "allow-remote-access"; then + echo "✗ ERROR: 0.0.0.0 SHOULD trigger --allow-remote-access" + exit 1 + fi + echo "✓ 0.0.0.0 correctly triggers remote access" + + docker-proxy-test: + name: Test Production Proxy Configuration + needs: docker-build + # Skip this job for fork PRs since secrets aren't available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Mask secrets + run: | + echo "::add-mask::${{ secrets.CONSOLE_TOKEN }}" + echo "::add-mask::${{ secrets.CONSOLE_BASE_URL }}" + echo "::add-mask::test-token-12345" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate test SSL certificates + run: | + mkdir -p ssl + openssl req -x509 -newkey rsa:2048 \ + -keyout ssl/key.pem \ + -out ssl/cert.pem \ + -days 1 -nodes \ + -subj "/CN=localhost" + chmod 644 ssl/key.pem ssl/cert.pem + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: purple-mcp:proxy-test + load: true + + - name: Validate test token characteristics + run: | + TEST_TOKEN="test-token-12345" + + # Verify token contains only safe characters (alphanumeric, dash, underscore, plus, slash, equals) + # This is a basic whitelist for base64/hex tokens + if ! echo "$TEST_TOKEN" | grep -qE '^[A-Za-z0-9+/=_-]+$'; then + echo "✗ ERROR: Test token contains invalid characters" + echo "Token should only contain: A-Z, a-z, 0-9, +, /, =, _, -" + exit 1 + fi + echo "✓ Test token character validation passed" + + # Warn if token is too short (less than 16 characters is weak) + if [ ${#TEST_TOKEN} -lt 16 ]; then + echo "⚠ WARNING: Test token is short (${#TEST_TOKEN} chars). Production tokens should be 32+ bytes (44+ chars base64)" + fi + + - name: Test proxy configuration + run: | + export PURPLEMCP_AUTH_TOKEN="test-token-12345" + export PURPLEMCP_CONSOLE_TOKEN="${{ secrets.CONSOLE_TOKEN }}" + export PURPLEMCP_CONSOLE_BASE_URL="${{ secrets.CONSOLE_BASE_URL }}" + + # Verify token was substituted correctly in the generated config + GENERATED_CONFIG=$(docker run --rm \ + -v $(pwd)/deploy/nginx/nginx.conf.template:/tmp/nginx.conf.template:ro \ + -e PURPLEMCP_AUTH_TOKEN \ + nginx:1.27-alpine sh -c 'envsubst "\$PURPLEMCP_AUTH_TOKEN" < /tmp/nginx.conf.template') + + # Verify placeholder was replaced + if echo "$GENERATED_CONFIG" | grep -q '\${PURPLEMCP_AUTH_TOKEN}'; then + echo "✗ ERROR: Token placeholder was not substituted" + exit 1 + fi + + # Verify actual token value is present + if ! echo "$GENERATED_CONFIG" | grep -q "test-token-12345"; then + echo "✗ ERROR: Token value not found in generated config" + exit 1 + fi + + echo "✓ Token substitution verification passed" + + timeout 30 docker compose --profile production up -d || { + echo "✗ Failed to start services" + docker compose logs + exit 1 + } + + # Wait for services to be healthy with retry logic + echo "Waiting for services to start..." + for i in {1..15}; do + if docker compose ps | grep -q "purple-mcp-streamable-http" && \ + docker compose ps | grep -q "purple-mcp-proxy"; then + echo "✓ Services started" + break + fi + if [ $i -eq 15 ]; then + echo "✗ Services did not start in time" + docker compose logs + docker compose down + exit 1 + fi + sleep 2 + done + + # Check if services are running + if ! docker compose ps | grep -q purple-mcp-streamable-http; then + echo "✗ MCP service is not running" + docker compose logs purple-mcp-streamable-http + docker compose down + exit 1 + fi + + if ! docker compose ps | grep -q purple-mcp-proxy; then + echo "✗ Proxy service is not running" + docker compose logs purple-mcp-proxy + docker compose down + exit 1 + fi + + echo "✓ Proxy configuration is valid and services are running" + + # Wait for backend to become healthy + echo "Waiting for backend to become healthy..." + for i in {1..30}; do + if docker compose ps purple-mcp-streamable-http | grep -q "healthy"; then + echo "✓ Backend is healthy" + break + fi + if [ $i -eq 30 ]; then + echo "✗ Backend did not become healthy in time" + docker compose logs purple-mcp-streamable-http + docker compose down + exit 1 + fi + sleep 1 + done + + # Test authentication: 401 without Authorization header + echo "Testing auth: expecting 401 without auth header..." + http_code=$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 10 https://localhost/) + if [ "$http_code" != "401" ]; then + echo "✗ Expected 401 without auth header, got $http_code" + docker compose logs + docker compose down + exit 1 + fi + echo "✓ Correctly returns 401 without auth header" + + # Test authentication: 403 with invalid token + echo "Testing auth: expecting 403 with invalid token..." + http_code=$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 10 -H "Authorization: Bearer invalid-token" https://localhost/) + if [ "$http_code" != "403" ]; then + echo "✗ Expected 403 with invalid token, got $http_code" + docker compose logs + docker compose down + exit 1 + fi + echo "✓ Correctly returns 403 with invalid token" + + # Test authentication: 200 with valid token (health endpoint) + echo "Testing auth: expecting 200 with valid token..." + http_code=$(curl -k -s -o /dev/null -w "%{http_code}" --max-time 10 -H "Authorization: Bearer test-token-12345" https://localhost/health) + if [ "$http_code" != "200" ]; then + echo "✗ Expected 200 with valid token, got $http_code" + docker compose logs + docker compose down + exit 1 + fi + echo "✓ Correctly returns 200 with valid token" + + echo "✓ All authentication checks passed" + docker compose down diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..61a1c19 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish Docker Image + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index c58e32b..c027f58 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ wheels/ /.env.test /.coverage /coverage.xml + +# SSL and production files +/ssl/ +*.pem +*.key +*.crt +.env.production +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1888755 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.5.1] - 2025-11-08 + +### Added + +- Docker deployment support with multi-stage Dockerfile +- Docker Compose configurations for all MCP transport modes +- Nginx reverse proxy with bearer token authentication +- Production deployment guide (PRODUCTION_SETUP.md) +- Docker deployment documentation (DOCKER.md) +- CI/CD workflow for Docker image publishing to GHCR (on release only) +- Docker startup tests for all transport modes +- Kubernetes and cloud load balancer deployment examples +- Network allowlist guidance for `/internal/health` endpoint +- Security warning when binding to non-loopback addresses +- Bold warnings about self-signed certificates in production + +### Changed + +- Updated .gitignore to exclude SSL certificates and production files +- Enhanced CONTRIBUTING.md with Docker instructions +- Updated README.md with Docker deployment section +- Simplified verbose comments across Docker configuration files +- Aligned image publishing documentation (release tags only) +- Standardized nginx version references to 1.27-alpine + +### Fixed + +- Nginx authentication uses `map` directive instead of negated regex +- Docker healthcheck installs wget in runtime image +- Docker entrypoint uses argv form for safer execution +- Pinned nginx image to 1.27-alpine +- CI workflow healthcheck reliability with retry logic + +### Security + +- Nginx proxy with TLS 1.2+, strong ciphers, and security headers +- IP-restricted `/internal/health` endpoint for Docker health checks +- Docker entrypoint validates placeholder tokens and uses `set -eu` +- Conditional `--allow-remote-access` flag for non-loopback bindings +- CI workflows mask secrets and validate auth flow +- Runtime warnings for unsafe network exposure + +## [0.5.0] - 2024-11-05 + +### Added + +- Initial public release +- Purple AI tool for natural language security queries +- SDL (Singularity Data Lake) query execution and timestamp utilities +- Alerts management (list, search, get details, notes, history) +- Misconfigurations management for cloud and Kubernetes environments +- Vulnerabilities management and tracking +- Inventory management for unified asset tracking +- Purple AI utility tools (status checks, available tools listing) +- Support for three MCP transport modes: stdio, SSE, and streamable-http +- Comprehensive test suite with unit and integration tests +- Type checking with mypy (strict mode) +- Code quality enforcement with ruff +- Automated CI/CD with GitHub Actions +- Comprehensive documentation (README, CONTRIBUTING, SECURITY) + +[0.5.1]: https://github.com/Sentinel-One/purple-mcp/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/Sentinel-One/purple-mcp/releases/tag/v0.5.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c965673..c19c2ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,35 @@ git clone --recurse-submodules **Note**: All submodules use HTTPS URLs for consistent access without SSH keys. +### Docker + +Build and run the Docker image locally: + +```bash +# Build locally +DOCKER_BUILDKIT=1 docker build -t purple-mcp:dev . + +# Test a specific mode +export PURPLEMCP_CONSOLE_TOKEN="your_token" +export PURPLEMCP_CONSOLE_BASE_URL="https://your-console.sentinelone.net" + +docker run -p 8000:8000 \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + purple-mcp:dev \ + --mode streamable-http + +# Test all modes with compose +cat > .env << EOF +PURPLEMCP_CONSOLE_TOKEN=your_token +PURPLEMCP_CONSOLE_BASE_URL=https://your-console.sentinelone.net +EOF + +docker compose --profile all up +``` + +Note: Production-ready images are published to `ghcr.io/sentinel-one/purple-mcp` on on release tags. See [DOCKER.md](DOCKER.md) for deployment options and [PRODUCTION_SETUP.md](PRODUCTION_SETUP.md) for production with authentication. + ## Architecture: Tools vs Libraries Purple MCP follows a strict separation between **libraries** (`libs/`) and **tools** (`tools/`). Understanding this distinction is crucial for contributors: diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..d3193cc --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,337 @@ +# Docker Deployment + +This guide covers running Purple MCP in Docker for development and production. + +## Requirements + +**Docker versions:** +- Docker Engine 20.10+ (or Docker Desktop 4.0+) +- Docker Compose V2 (2.0+) + +The production profile uses Docker Compose V2 features and security options (`security_opt`, `cap_drop`) that require these minimum versions. + +**Verify your versions:** +```bash +docker --version +# Should show: Docker version 20.10.0 or higher + +docker compose version +# Should show: Docker Compose version v2.0.0 or higher +``` + +**Note:** Docker Compose V1 (`docker-compose` with a hyphen) is deprecated and not supported. Use `docker compose` (space, not hyphen) for V2. + +## Getting the Image + +Pre-built images are published to `ghcr.io/sentinel-one/purple-mcp`: + +```bash +# Pull the latest image +docker pull ghcr.io/sentinel-one/purple-mcp:latest + +# Or a specific version +docker pull ghcr.io/sentinel-one/purple-mcp:v0.5.1 +``` + +Images are automatically published on release tags (e.g., `v0.5.1`). + +## Building Locally + +```bash +# Build locally +docker build -t purple-mcp:latest . + +# Build with BuildKit (faster caching) +DOCKER_BUILDKIT=1 docker build -t purple-mcp:latest . +``` + +## Running with Docker + +All examples below assume your credentials are set: + +```bash +export PURPLEMCP_CONSOLE_TOKEN="your_token" +export PURPLEMCP_CONSOLE_BASE_URL="https://your-console.sentinelone.net" +``` + +### Streamable-HTTP (Recommended) + +```bash +docker run -p 8000:8000 \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + purple-mcp:latest \ + --mode streamable-http +``` + +### SSE Mode + +```bash +docker run -p 8000:8000 \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + purple-mcp:latest \ + --mode sse +``` + +### STDIO Mode (Local) + +```bash +docker run -it \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + purple-mcp:latest \ + --mode stdio +``` + +## Docker Compose + +### Quick Start + +```bash +# Create .env file +cat > .env << EOF +PURPLEMCP_CONSOLE_TOKEN=your_token +PURPLEMCP_CONSOLE_BASE_URL=https://your-console.sentinelone.net +EOF + +# Run streamable-http mode +docker compose --profile streamable-http up +``` + +### Available Profiles + +- `streamable-http` - HTTP streaming transport (recommended) +- `sse` - Server-Sent Events transport +- `stdio` - STDIO mode (interactive, for testing) +- `proxy` - Nginx reverse proxy with authentication +- `production` - Streamable-HTTP + authenticated reverse proxy (recommended for production) +- `all` - All modes + +```bash +# Run all HTTP modes +docker compose --profile all up + +# Run production with authentication +docker compose --profile production up +``` + +## Environment Variables + +**Required:** +- `PURPLEMCP_CONSOLE_TOKEN` - SentinelOne service user token +- `PURPLEMCP_CONSOLE_BASE_URL` - Console URL (e.g., `https://console.sentinelone.net`) + +**Optional:** +- `PURPLEMCP_CONSOLE_GRAPHQL_ENDPOINT` - Default: `/web/api/v2.1/graphql` +- `PURPLEMCP_ALERTS_GRAPHQL_ENDPOINT` - Default: `/web/api/v2.1/unifiedalerts/graphql` +- `PURPLEMCP_MISCONFIGURATIONS_GRAPHQL_ENDPOINT` - Default: `/web/api/v2.1/xspm/findings/misconfigurations/graphql` +- `PURPLEMCP_VULNERABILITIES_GRAPHQL_ENDPOINT` - Default: `/web/api/v2.1/xspm/findings/vulnerabilities/graphql` +- `PURPLEMCP_INVENTORY_RESTAPI_ENDPOINT` - Default: `/web/api/v2.1/xdr/assets` +- `PURPLEMCP_ENV` - Environment type (default: `production`) +- `PURPLEMCP_LOGFIRE_TOKEN` - Optional observability + +## Production Deployment + +**Important:** Purple AI MCP has no built-in authentication. Always protect it with a reverse proxy in production. + +See [Production Setup Guide](PRODUCTION_SETUP.md) for a complete example with: +- Nginx reverse proxy +- Bearer token authentication +- HTTPS/TLS configuration +- Rate limiting +- Security headers + +Quick example: + +```bash +# Generate token +export PURPLEMCP_AUTH_TOKEN=$(openssl rand -hex 32) + +# Generate SSL certificates (self-signed for testing ONLY) +mkdir -p ssl +openssl req -x509 -newkey rsa:4096 \ + -keyout ssl/key.pem -out ssl/cert.pem \ + -days 365 -nodes -subj "/CN=localhost" + +# **WARNING: Self-signed certificates are for testing only, NOT for production use.** +# For production, use Let's Encrypt or your organization's certificate authority. + +# Start with proxy +docker compose --profile production up + +# Test with auth +curl -k -H "Authorization: Bearer $PURPLEMCP_AUTH_TOKEN" https://localhost:443/ +``` + +See [deploy/nginx/nginx.conf.template](deploy/nginx/nginx.conf.template) for the reverse proxy configuration. The template uses environment variable substitution (`envsubst`) to inject `PURPLEMCP_AUTH_TOKEN` at container startup. + +## Health Checks + +### Backend Services + +All HTTP-based backend services expose a health endpoint for direct access: + +```bash +# SSE mode +curl http://localhost:8000/health + +# Streamable-HTTP mode +curl http://localhost:8001/health +``` + +### Production Proxy + +When using the nginx proxy, the `/health` endpoint behavior changes for security: + +- **Backend `/health`** (direct): Publicly accessible, no authentication +- **Proxy `/health`**: Requires bearer token authentication +- **Proxy `/internal/health`**: IP-restricted (Docker internal networks only) + +Docker health checks use the IP-restricted `/internal/health` endpoint. For external monitoring, use authenticated requests: + +```bash +# Authenticated health check through proxy +curl -k -H "Authorization: Bearer $PURPLEMCP_AUTH_TOKEN" https://localhost/health + +# Or use any authenticated MCP endpoint +curl -k -H "Authorization: Bearer $PURPLEMCP_AUTH_TOKEN" https://localhost/ +``` + +## Troubleshooting + +### Container won't start + +```bash +docker logs + +# Run with verbose output +docker run -it \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + purple-mcp:latest \ + --mode streamable-http --verbose +``` + +### Authentication errors from SentinelOne + +Check your token: +- Must be Account or Site level (not Global) +- Get from: Policy & Settings → User Management → Service Users +- May have expired + +### Port already in use + +```bash +docker run -p 8001:8000 purple-mcp:latest --mode streamable-http +``` + +## Kubernetes Deployment + +Example Deployment with authentication: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: purple-mcp +spec: + replicas: 1 + selector: + matchLabels: + app: purple-mcp + template: + metadata: + labels: + app: purple-mcp + spec: + containers: + - name: purple-mcp + image: ghcr.io/sentinel-one/purple-mcp:latest + ports: + - containerPort: 8000 + env: + - name: PURPLEMCP_CONSOLE_TOKEN + valueFrom: + secretKeyRef: + name: purple-mcp + key: console-token + - name: PURPLEMCP_CONSOLE_BASE_URL + valueFrom: + configMapKeyRef: + name: purple-mcp + key: console-url + - name: MCP_MODE + value: "streamable-http" + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: purple-mcp +spec: + selector: + app: purple-mcp + ports: + - port: 8000 + targetPort: 8000 + type: ClusterIP +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: purple-mcp +data: + console-url: https://your-console.sentinelone.net +--- +apiVersion: v1 +kind: Secret +metadata: + name: purple-mcp +type: Opaque +stringData: + console-token: "your-token-here" +``` + +Deploy it: + +```bash +kubectl apply -f deployment.yaml +kubectl port-forward svc/purple-mcp 8000:8000 +``` + +## Security Notes + +**Authentication:** Purple AI MCP does not include authentication. You must run it behind a reverse proxy for any network-accessible deployment. See [Production Setup](PRODUCTION_SETUP.md) for a working example with Nginx. + +**TLS/SSL:** The proxy is configured with modern TLS and security headers. For production, use valid certificates (not self-signed). Let's Encrypt is recommended. + +**Secrets:** Never commit `.env` files or certificates to git. The project's `.gitignore` already excludes these. See [SECURITY.md](SECURITY.md) for additional security guidance. + +**Rate limiting:** The proxy enforces rate limits (10 req/s) to prevent abuse. + +**Token rotation:** Rotate authentication tokens regularly. See [Production Setup](PRODUCTION_SETUP.md#token-management) for procedures. + +## Next Steps + +- [Production Setup Guide](PRODUCTION_SETUP.md) - Complete production deployment +- [Contributing Guide](CONTRIBUTING.md) - Docker development +- [Main README](README.md) - Project overview diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9a0d67a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.10 + +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project --no-dev + +COPY src src +COPY LICENSE LICENSE +COPY README.md README.md + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +FROM python:3.14-slim + +LABEL org.opencontainers.image.title="Purple MCP Server" +LABEL org.opencontainers.image.description="SentinelOne Purple AI MCP Server" +LABEL org.opencontainers.image.source="https://github.com/Sentinel-One/purple-mcp" +LABEL org.opencontainers.image.version="0.5.1" + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONIOENCODING=utf-8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 1000 -s /sbin/nologin mcp + +WORKDIR /app + +COPY --from=builder --chown=mcp:mcp /app /app + +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh && chown mcp:mcp /app/docker-entrypoint.sh + +ENV VIRTUAL_ENV=/app/.venv \ + PATH="/app/.venv/bin:$PATH" + +USER mcp + +ENV MCP_MODE=stdio \ + MCP_HOST=0.0.0.0 \ + MCP_PORT=8000 + +EXPOSE 8000 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/PRODUCTION_SETUP.md b/PRODUCTION_SETUP.md new file mode 100644 index 0000000..de2f2b2 --- /dev/null +++ b/PRODUCTION_SETUP.md @@ -0,0 +1,473 @@ +# Production Setup + +This guide sets up Purple MCP with authentication for production use. + +## Requirements + +**Docker versions:** +- Docker Engine 20.10+ (or Docker Desktop 4.0+) +- Docker Compose V2 (2.0+) + +The production deployment uses security hardening features (`security_opt`, `cap_drop`) that require these minimum versions. + +**Verify your versions:** +```bash +docker --version # Should show 20.10.0+ +docker compose version # Should show v2.0.0+ +``` + +**Note:** Use `docker compose` (V2, with space) not `docker-compose` (V1, deprecated). + +## Quick Reference + +Pre-built images: `ghcr.io/sentinel-one/purple-mcp:latest` + +```bash +docker pull ghcr.io/sentinel-one/purple-mcp:latest +docker compose pull +``` + +## Quick Start + +> **Note**: This quick start uses nginx for simplicity. For production deployments on AWS, GCP, or Azure, we recommend using native cloud load balancers (ALB, Cloud Load Balancing, or Application Gateway). See [Cloud Load Balancer Setup](#cloud-load-balancer-setup) for configuration details. + +### 1. Generate credentials and token + +```bash +# Create .env file with your SentinelOne credentials +cat > .env << EOF +PURPLEMCP_CONSOLE_TOKEN=your_service_token +PURPLEMCP_CONSOLE_BASE_URL=https://your-console.sentinelone.net +PURPLEMCP_AUTH_TOKEN=$(openssl rand -hex 32) +PURPLEMCP_ENV=production +EOF + +chmod 600 .env +``` + +### 2. Generate SSL certificates + +**For testing/staging only (NOT for production):** + +```bash +mkdir -p ssl +openssl req -x509 -newkey rsa:4096 \ + -keyout ssl/key.pem -out ssl/cert.pem \ + -days 365 -nodes \ + -subj "/CN=your-domain.com" +``` + +**WARNING: Self-signed certificates above are for development/testing only. Never use self-signed certificates in production deployments.** + +**For production with Let's Encrypt:** + +```bash +docker run --rm -v $(pwd)/ssl:/etc/letsencrypt certbot/certbot \ + certonly --standalone -d your-domain.com + +# Copy certificates to expected location +cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem +cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem +``` + +### 3. Start the services + +```bash +# Start with production profile (includes reverse proxy with auth) +docker compose --profile production up -d + +# Verify services are running +docker compose ps +``` + +### 4. Test + +```bash +# Get the auth token +TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) + +# Test with authentication +curl -k -H "Authorization: Bearer $TOKEN" https://localhost/ +``` + +## Architecture + +This guide covers the nginx reverse proxy setup, which works well for development and self-hosted deployments. For production deployments on AWS, GCP, or Azure, we recommend using your cloud provider's native load balancer (Application Load Balancer, Cloud Load Balancing, or Application Gateway) instead. See [Cloud Load Balancer Setup](#cloud-load-balancer-setup) for details. + +**Nginx architecture:** +``` +Client → nginx (443) → Purple MCP (8000) → SentinelOne API +``` + +**Cloud load balancer architecture:** +``` +Client → ALB/GCLB/App Gateway (443) → Purple MCP (8000) → SentinelOne API +``` + +Cloud load balancers provide managed SSL certificates, better DDoS protection, simpler horizontal scaling, and avoid the nginx rate limiting issues described below. + +### How Auth Works + +The nginx config uses environment variable substitution. At startup, `envsubst` replaces `${PURPLEMCP_AUTH_TOKEN}` in the template with your actual token from the environment. This means the token is never hardcoded in the config file - it's only in memory at runtime. + +### Rate Limiting Limitation + +The nginx configuration has a known limitation with rate limiting. Due to nginx's request processing phases, authentication failures (401/403 responses) occur before rate limiting is applied. This means an attacker can make unlimited authentication attempts without being throttled. + +**Mitigation**: Use a cryptographically strong random token with sufficient entropy. A 256-bit token (32 bytes) makes brute force attacks computationally infeasible even without rate limiting: + +```bash +# Recommended: Base64-encoded (URL-safe characters) +openssl rand -base64 32 + +# Alternative: Hexadecimal +openssl rand -hex 32 +``` + +**Token Requirements:** +- Use only base64 or hexadecimal characters (alphanumeric + `/+=` for base64, `0-9a-f` for hex) +- Minimum 32 bytes of entropy (generates 44+ character base64 or 64 character hex string) +- It's safe to quote tokens in `.env` files (quotes will be stripped by Docker) +- Avoid special shell characters that might cause issues: `$`, backticks, `\`, `!` (in some shells) + +For deployments requiring rate limiting on authentication failures, consider using a cloud load balancer with WAF rules or moving authentication to the application layer. + +## Monitoring + +### View logs + +```bash +# All services +docker compose logs -f + +# Specific service +docker compose logs -f purple-mcp-proxy +docker compose logs -f purple-mcp-streamable-http +``` + +### Check authentication + +```bash +# View auth failures +docker compose logs purple-mcp-proxy | grep "Unauthorized" + +# Monitor in real-time +docker compose logs -f purple-mcp-proxy | grep -E "Unauthorized|Forbidden" + +# Verify token validation is working (should be rejected) +curl -k -H "Authorization: Bearer invalid-token" https://localhost/ + +# Verify correct token is accepted +TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) +curl -k -H "Authorization: Bearer $TOKEN" https://localhost/ +``` + +### Verify token is NOT hardcoded + +The nginx configuration uses environment variable substitution for security. You can verify the token is properly substituted: + +```bash +# The template file should contain ${PURPLEMCP_AUTH_TOKEN} placeholder +grep "PURPLEMCP_AUTH_TOKEN" deploy/nginx/nginx.conf.template + +# Verify the placeholder was substituted (check that literal string is absent) +docker exec purple-mcp-proxy grep '\${PURPLEMCP_AUTH_TOKEN}' /etc/nginx/nginx.conf +# Should return nothing (exit code 1) - confirms envsubst replaced the placeholder + +# Verify the running config contains your actual token value +TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) +docker exec purple-mcp-proxy grep "$TOKEN" /etc/nginx/nginx.conf +# Should find matches (exit code 0) - confirms your token is active in nginx +``` + +### Health checks + +The nginx proxy changes health endpoint behavior for security: + +- **Backend `/health`** (direct): Publicly accessible, no authentication +- **Proxy `/health`**: Requires bearer token authentication +- **Proxy `/internal/health`**: IP-restricted to internal networks only (localhost IPv4 `127.0.0.1`, localhost IPv6 `::1`, and Docker bridge networks `172.16.0.0/12`) + +Docker health checks use the IP-restricted `/internal/health` endpoint. For external monitoring: + +```bash +TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) + +# Authenticated health check +curl -k -H "Authorization: Bearer $TOKEN" https://localhost/health + +# Or any authenticated endpoint +curl -k -H "Authorization: Bearer $TOKEN" https://localhost/ +``` + +## Maintenance + +### Update token + +```bash +# Generate new token +NEW_TOKEN=$(openssl rand -hex 32) + +# Update .env +sed -i "s/PURPLEMCP_AUTH_TOKEN=.*/PURPLEMCP_AUTH_TOKEN=$NEW_TOKEN/" .env + +# Restart proxy +docker compose restart purple-mcp-proxy +``` + +### Renew Let's Encrypt certificate + +```bash +# Renew (should be automatic with certbot) +docker run --rm -v $(pwd)/ssl:/etc/letsencrypt certbot/certbot renew + +# Restart proxy +docker compose restart purple-mcp-proxy +``` + +### Renew self-signed certificate (testing only) + +**WARNING: Self-signed certificates are for testing only, not production.** + +```bash +openssl req -x509 -newkey rsa:4096 \ + -keyout ssl/key.pem -out ssl/cert.pem \ + -days 365 -nodes \ + -subj "/CN=your-domain.com" + +docker compose restart purple-mcp-proxy +``` + +## Troubleshooting + +### Services won't start + +```bash +docker compose logs + +# Validate nginx config template +export PURPLEMCP_AUTH_TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) +docker run --rm \ + -v $(pwd)/deploy/nginx/nginx.conf.template:/tmp/nginx.conf.template:ro \ + -e PURPLEMCP_AUTH_TOKEN \ + nginx:1.27-alpine sh -c 'envsubst "\$PURPLEMCP_AUTH_TOKEN" < /tmp/nginx.conf.template > /etc/nginx/nginx.conf && nginx -t' +``` + +### Certificate issues + +```bash +# Check expiration +openssl x509 -in ssl/cert.pem -noout -dates + +# View certificate details +openssl x509 -in ssl/cert.pem -text -noout +``` + +### Port already in use + +Either change the port in `docker-compose.yml` or stop the conflicting service: + +```bash +lsof -i :443 +kill -9 +``` + +### Can't connect to SentinelOne + +```bash +# Check logs +docker compose logs purple-mcp-streamable-http + +# Verify credentials are correct +# - Token must be Account or Site level (not Global) +# - Token must not be expired +# - Base URL must be reachable from container +``` + +## Configuration Reference + +### Environment Variables + +See [docker-compose.yml](docker-compose.yml) for a complete list. Key production variables: + +- `PURPLEMCP_AUTH_TOKEN` - Bearer token for proxy authentication (required) +- `PURPLEMCP_CONSOLE_TOKEN` - SentinelOne service user token +- `PURPLEMCP_CONSOLE_BASE_URL` - SentinelOne console URL +- `PURPLEMCP_ENV` - Set to `production` + +### Nginx Configuration + +See [deploy/nginx/nginx.conf.template](deploy/nginx/nginx.conf.template) for the reverse proxy configuration template. The template uses `envsubst` to inject `PURPLEMCP_AUTH_TOKEN` at runtime. Notable features: + +- Bearer token validation (`Authorization: Bearer `) +- HTTPS/TLS with modern ciphers +- Security headers (HSTS, X-Frame-Options, X-Content-Type-Options) +- Rate limiting (10 req/s) +- Streaming support for MCP +- Health check endpoint (no auth required) + +### Security Hardening + +The production profile (`docker compose --profile production`) includes security hardening: + +- **`no-new-privileges:true`**: Prevents privilege escalation within containers +- **`cap_drop: ALL`**: Drops all Linux capabilities; services run with minimal privileges +- **Read-only volumes**: Configuration and SSL certificates mounted as read-only + +These settings follow the principle of least privilege. The containers can still function normally for their intended purpose (running Python/nginx) but cannot perform privileged operations. + +## Cloud Load Balancer Setup + +Purple MCP uses Server-Sent Events (SSE) for streaming responses, which requires long-lived HTTP connections. The critical configuration across all cloud providers is the idle timeout - default values are typically too short and will cause connections to drop. + +Purple MCP runs in stateless mode, meaning each request is independent and session state is not maintained. This simplifies deployment: you don't need sticky sessions or session affinity, and you can scale horizontally by adding more backend instances without coordination between them. + +### AWS Application Load Balancer + +The default ALB idle timeout of 60 seconds will cause SSE connections to fail. Increase it to at least 300 seconds (5 minutes) or higher depending on your use case: + +```hcl +resource "aws_lb" "purple_mcp" { + name = "purple-mcp-alb" + load_balancer_type = "application" + subnets = var.public_subnets + security_groups = [aws_security_group.alb.id] + + idle_timeout = 300 # 5 minutes for SSE +} + +resource "aws_lb_target_group" "purple_mcp" { + name = "purple-mcp-tg" + port = 8000 + protocol = "HTTP" + vpc_id = var.vpc_id + + health_check { + path = "/health" + interval = 30 + } + + # Sticky sessions not required - Purple MCP is stateless + stickiness { + enabled = false + } +} +``` + +Configure your HTTPS listener to forward to this target group and attach an SSL certificate from ACM. For authentication, you can implement token validation in the application layer or use AWS WAF rules. + +Reference: [AWS Guidance for Deploying MCP Servers](https://aws.amazon.com/solutions/guidance/deploying-model-context-protocol-servers-on-aws/) + +### Google Cloud Load Balancing + +The default 30 second backend timeout is too short for SSE connections. Set it significantly higher - 24 hours (86400 seconds) works well: +```hcl +resource "google_compute_backend_service" "purple_mcp" { + name = "purple-mcp-backend" + protocol = "HTTP" + timeout_sec = 86400 # 24 hours for SSE + + backend { + group = google_compute_instance_group_manager.purple_mcp.instance_group + } + + health_checks = [google_compute_health_check.purple_mcp.id] + + # Session affinity not required - stateless mode + session_affinity = "NONE" +} + +resource "google_compute_health_check" "purple_mcp" { + name = "purple-mcp-health" + + http_health_check { + port = 8000 + request_path = "/health" + } +} +``` + +Session affinity is set to `NONE` since Purple MCP doesn't maintain session state. For additional security, consider using Cloud Armor for DDoS protection and WAF capabilities. + +### Azure Application Gateway / Load Balancer + +Azure enforces a 4 minute minimum idle timeout, which is the bare minimum for SSE. Configure a higher value if your deployment allows: +```hcl +resource "azurerm_lb" "purple_mcp" { + name = "purple-mcp-lb" + location = var.location + resource_group_name = var.resource_group_name + + frontend_ip_configuration { + name = "PublicIPAddress" + public_ip_address_id = azurerm_public_ip.purple_mcp.id + } +} + +resource "azurerm_lb_probe" "purple_mcp" { + loadbalancer_id = azurerm_lb.purple_mcp.id + name = "purple-mcp-health" + protocol = "Http" + port = 8000 + request_path = "/health" +} +``` + +Session persistence is not required since Purple MCP operates in stateless mode. If the 4 minute timeout proves insufficient, implement client-side keepalives (sending data every 3 minutes) to maintain the connection. + +For enhanced security, use Azure WAF with Application Gateway or integrate Azure AD for OAuth 2.0 authentication. + +### Kubernetes Deployments + +If you're running Purple MCP on EKS, GKE, or AKS, configure health probes in your deployment manifest: + +```yaml +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + +readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +Purple MCP operates in stateless mode by default, making it well-suited for Kubernetes deployments. You can enable horizontal pod autoscaling to handle varying loads, and the load balancer will distribute requests across pods without requiring session affinity. + +## Pre-launch Checklist + +**For cloud load balancer deployments (recommended):** +- [ ] Idle timeout configured appropriately (300+ seconds for AWS, 86400 seconds for GCP, 240+ seconds for Azure) +- [ ] Health checks configured to use `/health` endpoint +- [ ] SSL/TLS certificate configured (ACM, Google-managed certificates, or Azure certificates) +- [ ] Auto-scaling enabled for backend instances +- [ ] Sticky sessions disabled (not required for stateless mode) +- [ ] WAF or Cloud Armor configured for additional security (optional but recommended) + +**For nginx reverse proxy deployments:** +- [ ] Valid SSL certificate installed (not self-signed for production) +- [ ] Token substitution verified: `docker exec purple-mcp-proxy grep '\${PURPLEMCP_AUTH_TOKEN}' /etc/nginx/nginx.conf` should return nothing (placeholder should be replaced with actual token) +- [ ] Verify actual token is present: `TOKEN=$(grep PURPLEMCP_AUTH_TOKEN .env | cut -d= -f2) && docker exec purple-mcp-proxy grep "$TOKEN" /etc/nginx/nginx.conf` should find matches +- [ ] Understanding of rate limiting limitation documented below + +**Security:** +- [ ] Strong authentication token generated: `openssl rand -base64 32` +- [ ] `.env` file excluded from version control +- [ ] Firewall rules or security groups properly configured + +**Operations:** +- [ ] All health checks passing +- [ ] Authentication tested with both valid and invalid tokens +- [ ] Monitoring and alerting configured +- [ ] Token rotation procedure documented and understood by team + +## Next Steps + +- [Docker Deployment Guide](DOCKER.md) - More deployment options +- [Troubleshooting](DOCKER.md#troubleshooting) - Common issues +- [Kubernetes Deployment](DOCKER.md#kubernetes-deployment) - Deploy to K8s diff --git a/README.md b/README.md index dca890f..0c9feba 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Purple AI MCP is a read-only service - you cannot make changes to your account o ## Quick Start +### Using uv (Recommended for Local Development or Deployment) + ```bash # Install uv if you don't have it curl -LsSf https://astral.sh/uv/install.sh | sh @@ -39,6 +41,27 @@ export PURPLEMCP_CONSOLE_BASE_URL="https://your-console.sentinelone.net" uvx --from git+https://github.com/Sentinel-One/purple-mcp.git purple-mcp --mode=stdio ``` +### Using Docker + +```bash +export PURPLEMCP_CONSOLE_TOKEN="your_token" +export PURPLEMCP_CONSOLE_BASE_URL="https://your-console.sentinelone.net" + +docker run -p 8000:8000 \ + -e PURPLEMCP_CONSOLE_TOKEN \ + -e PURPLEMCP_CONSOLE_BASE_URL \ + ghcr.io/sentinel-one/purple-mcp:latest \ + --mode streamable-http +``` + +Images are published to [`ghcr.io/sentinel-one/purple-mcp`](https://github.com/Sentinel-One/purple-mcp/pkgs/container/purple-mcp) on release tags. + +For production deployments, see [Deployment Guide](DOCKER.md). + +**Note:** Purple AI MCP does not include built-in authentication. For network-exposed deployments, place it behind a reverse proxy or load balancer. See [Production Setup](PRODUCTION_SETUP.md) for cloud load balancer configurations (AWS ALB, GCP Cloud Load Balancing, Azure Application Gateway) or nginx examples for self-hosted deployments. + +--- + Your token needs Account or Site level permissions (not Global). Get one from Policy & Settings → User Management → Service Users in your console. Currently, this server only supports tokens that have access to a single Account or Site. If you need to access multiple sites, you will need to run multiple MCP servers with Account-specific or Site-specific tokens. ## Clients diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b3a19c8 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,16 @@ +# Deployment Configurations + +This directory contains deployment and infrastructure configurations. + +## nginx/ + +Reverse proxy configuration for production deployments. See [nginx.conf.template](nginx/nginx.conf.template) for: +- Bearer token authentication +- HTTPS/TLS configuration +- Security headers +- Rate limiting +- Streaming support for MCP + +Used by the `purple-mcp-proxy` service in [docker-compose.yml](../docker-compose.yml). + +For production setup instructions, see [PRODUCTION_SETUP.md](../PRODUCTION_SETUP.md). diff --git a/deploy/nginx/nginx.conf.template b/deploy/nginx/nginx.conf.template new file mode 100644 index 0000000..a46a0b9 --- /dev/null +++ b/deploy/nginx/nginx.conf.template @@ -0,0 +1,241 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 10M; + + # Extract bearer token from Authorization header + map $http_authorization $auth_token { + ~^Bearer\s+(.+)$ $1; + default ""; + } + + # Rate limiting (prevent abuse) + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m; + + # Upstream backend (Purple MCP streamable-http server) + upstream purple_mcp_backend { + server purple-mcp-streamable-http:8000; + } + + # HTTP redirect to HTTPS + server { + listen 80; + server_name _; + return 301 https://$host$request_uri; + } + + # HTTPS server with token authentication + server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name _; + + # SSL/TLS Configuration (replace with production certificates) + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # SECURITY NOTE: Rate limiting limitation + # Auth checks (if/return) execute in REWRITE phase, before limit_req (PREACCESS phase). + # This means 401/403 responses bypass rate limiting, allowing unlimited auth attempts. + # Mitigation: Use strong random tokens (openssl rand -base64 32) to make brute force infeasible. + + # Internal health check endpoint (restricted to internal networks) + # Default allows localhost (IPv4 and IPv6) and Docker default bridge networks (172.16-31.x.x) + # For other private networks, add: 10.0.0.0/8 or 192.168.0.0/16 + location /internal/health { + allow 127.0.0.1; + allow ::1; + allow 172.16.0.0/12; + deny all; + + access_log off; + proxy_pass http://purple_mcp_backend/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Main MCP endpoint (requires authentication) + location / { + limit_req zone=auth_limit burst=10 nodelay; + + # Token-based authentication using extracted $auth_token from map + if ($auth_token = "") { + return 401 "Unauthorized: Missing or invalid Authorization header\n"; + } + + # Validate token (compare with PURPLEMCP_AUTH_TOKEN environment variable) + if ($auth_token != "${PURPLEMCP_AUTH_TOKEN}") { + return 403 "Forbidden: Invalid token\n"; + } + + # Proxy to backend + proxy_pass http://purple_mcp_backend; + + # Streaming support (important for MCP) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_request_buffering off; + + # Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Timeouts for long-running queries + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # Deny access to sensitive paths + location ~ /\.env { + return 403; + } + + location ~ /config { + return 403; + } + } + + # Alternative: HTTPS on non-standard port (8443) for development + # This is useful if you can't bind to port 443 + server { + listen 8443 ssl http2; + listen [::]:8443 ssl http2; + server_name _; + + # SSL/TLS Configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + + # Internal health check endpoint (restricted to internal networks) + # Default allows localhost (IPv4 and IPv6) and Docker default bridge networks (172.16-31.x.x) + # For other private networks, add: 10.0.0.0/8 or 192.168.0.0/16 + location /internal/health { + allow 127.0.0.1; + allow ::1; + allow 172.16.0.0/12; + deny all; + + access_log off; + proxy_pass http://purple_mcp_backend/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Public health check endpoint (requires authentication) + location /health { + limit_req zone=auth_limit burst=5 nodelay; + + # Token-based authentication using extracted $auth_token from map + if ($auth_token = "") { + return 401 "Unauthorized: Missing or invalid Authorization header\n"; + } + + if ($auth_token != "${PURPLEMCP_AUTH_TOKEN}") { + return 403 "Forbidden: Invalid token\n"; + } + + access_log off; + proxy_pass http://purple_mcp_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Main MCP endpoint with token authentication + location / { + limit_req zone=auth_limit burst=10 nodelay; + + # Token-based authentication using extracted $auth_token from map + if ($auth_token = "") { + return 401 "Unauthorized: Missing or invalid Authorization header\n"; + } + + if ($auth_token != "${PURPLEMCP_AUTH_TOKEN}") { + return 403 "Forbidden: Invalid token\n"; + } + + proxy_pass http://purple_mcp_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_request_buffering off; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location ~ /\.env { + return 403; + } + + location ~ /config { + return 403; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..101d696 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,118 @@ +services: + purple-mcp-sse: + build: + context: . + dockerfile: Dockerfile + container_name: purple-mcp-sse + environment: + MCP_MODE: sse + MCP_HOST: 0.0.0.0 + MCP_PORT: 8000 + # Required: SentinelOne Console configuration + PURPLEMCP_CONSOLE_BASE_URL: ${PURPLEMCP_CONSOLE_BASE_URL} + PURPLEMCP_CONSOLE_TOKEN: ${PURPLEMCP_CONSOLE_TOKEN} + + # Optional: Observability + PURPLEMCP_ENV: ${PURPLEMCP_ENV:-production} + PURPLEMCP_LOGFIRE_TOKEN: ${PURPLEMCP_LOGFIRE_TOKEN:-} + ports: + - "8000:8000" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + profiles: + - sse + - all + + purple-mcp-streamable-http: + build: + context: . + dockerfile: Dockerfile + container_name: purple-mcp-streamable-http + environment: + MCP_MODE: streamable-http + MCP_HOST: 0.0.0.0 + MCP_PORT: 8000 + # Required: SentinelOne Console configuration + PURPLEMCP_CONSOLE_BASE_URL: ${PURPLEMCP_CONSOLE_BASE_URL} + PURPLEMCP_CONSOLE_TOKEN: ${PURPLEMCP_CONSOLE_TOKEN} + # Optional: Custom GraphQL endpoints + PURPLEMCP_CONSOLE_GRAPHQL_ENDPOINT: ${PURPLEMCP_CONSOLE_GRAPHQL_ENDPOINT:-/web/api/v2.1/graphql} + PURPLEMCP_ALERTS_GRAPHQL_ENDPOINT: ${PURPLEMCP_ALERTS_GRAPHQL_ENDPOINT:-/web/api/v2.1/unifiedalerts/graphql} + PURPLEMCP_MISCONFIGURATIONS_GRAPHQL_ENDPOINT: ${PURPLEMCP_MISCONFIGURATIONS_GRAPHQL_ENDPOINT:-/web/api/v2.1/xspm/findings/misconfigurations/graphql} + PURPLEMCP_VULNERABILITIES_GRAPHQL_ENDPOINT: ${PURPLEMCP_VULNERABILITIES_GRAPHQL_ENDPOINT:-/web/api/v2.1/xspm/findings/vulnerabilities/graphql} + PURPLEMCP_INVENTORY_RESTAPI_ENDPOINT: ${PURPLEMCP_INVENTORY_RESTAPI_ENDPOINT:-/web/api/v2.1/xdr/assets} + # Optional: Observability + PURPLEMCP_ENV: ${PURPLEMCP_ENV:-production} + PURPLEMCP_LOGFIRE_TOKEN: ${PURPLEMCP_LOGFIRE_TOKEN:-} + ports: + - "8001:8000" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + profiles: + - streamable-http + - production + - all + + purple-mcp-stdio: + build: + context: . + dockerfile: Dockerfile + container_name: purple-mcp-stdio + environment: + MCP_MODE: stdio + # Required: SentinelOne Console configuration + PURPLEMCP_CONSOLE_BASE_URL: ${PURPLEMCP_CONSOLE_BASE_URL} + PURPLEMCP_CONSOLE_TOKEN: ${PURPLEMCP_CONSOLE_TOKEN} + # Optional: Custom GraphQL endpoints + PURPLEMCP_CONSOLE_GRAPHQL_ENDPOINT: ${PURPLEMCP_CONSOLE_GRAPHQL_ENDPOINT:-/web/api/v2.1/graphql} + PURPLEMCP_ALERTS_GRAPHQL_ENDPOINT: ${PURPLEMCP_ALERTS_GRAPHQL_ENDPOINT:-/web/api/v2.1/unifiedalerts/graphql} + PURPLEMCP_MISCONFIGURATIONS_GRAPHQL_ENDPOINT: ${PURPLEMCP_MISCONFIGURATIONS_GRAPHQL_ENDPOINT:-/web/api/v2.1/xspm/findings/misconfigurations/graphql} + PURPLEMCP_VULNERABILITIES_GRAPHQL_ENDPOINT: ${PURPLEMCP_VULNERABILITIES_GRAPHQL_ENDPOINT:-/web/api/v2.1/xspm/findings/vulnerabilities/graphql} + PURPLEMCP_INVENTORY_RESTAPI_ENDPOINT: ${PURPLEMCP_INVENTORY_RESTAPI_ENDPOINT:-/web/api/v2.1/xdr/assets} + # Optional: Observability + PURPLEMCP_ENV: ${PURPLEMCP_ENV:-production} + PURPLEMCP_LOGFIRE_TOKEN: ${PURPLEMCP_LOGFIRE_TOKEN:-} + stdin_open: true + tty: true + profiles: + - stdio + + purple-mcp-proxy: + image: nginx:1.27-alpine + container_name: purple-mcp-proxy + environment: + PURPLEMCP_AUTH_TOKEN: ${PURPLEMCP_AUTH_TOKEN:-your-secure-token-here} + NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx + volumes: + - ./deploy/nginx/nginx.conf.template:/etc/nginx/templates/nginx.conf.template:ro + - ./ssl:/etc/nginx/ssl:ro + ports: + - "80:80" + - "443:443" + depends_on: + - purple-mcp-streamable-http + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost/internal/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + profiles: + - proxy + - production + +networks: + default: + name: purple-mcp-network + driver: bridge diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..356e504 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +if [ "${PURPLEMCP_AUTH_TOKEN:-}" = "your-secure-token-here" ]; then + echo "ERROR: Default placeholder token detected!" >&2 + echo "The PURPLEMCP_AUTH_TOKEN environment variable is set to 'your-secure-token-here'," >&2 + echo "which is the default placeholder value and must not be used in production." >&2 + echo "" >&2 + echo "Please generate a strong random token:" >&2 + echo " openssl rand -base64 32" >&2 + echo "" >&2 + echo "And set it in your environment or .env file:" >&2 + echo " PURPLEMCP_AUTH_TOKEN=" >&2 + exit 1 +fi + +MCP_MODE="${MCP_MODE:-stdio}" +MCP_HOST="${MCP_HOST:-0.0.0.0}" +MCP_PORT="${MCP_PORT:-8000}" + +set -- python -u -m purple_mcp.cli --mode "$MCP_MODE" --host "$MCP_HOST" --port "$MCP_PORT" + +ALLOW_REMOTE_ACCESS=false + +# Conditionally add --allow-remote-access for HTTP modes binding to non-loopback addresses +case "$MCP_MODE" in + sse|streamable-http) + # Check explicitly for loopback addresses first + case "$MCP_HOST" in + localhost|127.0.0.1|::1) + # Loopback addresses - no remote access flag needed + ;; + *) + # All other addresses (0.0.0.0 and remote IPs) need remote access + set -- "$@" --allow-remote-access + ALLOW_REMOTE_ACCESS=true + ;; + esac + ;; + stdio) + ;; + *) + # For other modes, check for loopback + if [ "$MCP_HOST" != "localhost" ] && [ "$MCP_HOST" != "127.0.0.1" ] && [ "$MCP_HOST" != "::1" ]; then + set -- "$@" --allow-remote-access + ALLOW_REMOTE_ACCESS=true + fi + ;; +esac + +if [ "$ALLOW_REMOTE_ACCESS" = "true" ]; then + echo "WARNING: Purple MCP is binding to non-loopback address ($MCP_HOST) without built-in authentication." >&2 + echo "For production deployments, ensure this service runs behind a reverse proxy or load balancer." >&2 + echo "See: https://github.com/Sentinel-One/purple-mcp/blob/main/PRODUCTION_SETUP.md" >&2 + echo "" >&2 +fi + +exec "$@" diff --git a/pyproject.toml b/pyproject.toml index 7c0138d..8abb53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "purple-mcp" -version = "0.5.0" +version = "0.5.1" description = "Purple AI MCP Server" readme = "README.md" requires-python = ">=3.10" diff --git a/src/purple_mcp/__init__.py b/src/purple_mcp/__init__.py index 13b827b..208f619 100644 --- a/src/purple_mcp/__init__.py +++ b/src/purple_mcp/__init__.py @@ -5,4 +5,4 @@ and interacting with AI-powered security analysis services. """ -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/uv.lock b/uv.lock index 201ba61..ae89a8a 100644 --- a/uv.lock +++ b/uv.lock @@ -1864,7 +1864,7 @@ wheels = [ [[package]] name = "purple-mcp" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "click" },