Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
venv/
.git/
__pycache__/
*.pyc
*.db
.env
tests/
tools/
.github/
2 changes: 1 addition & 1 deletion .github/schedule-attack-simulation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ jobs:
- name: Install dependencies
run: pip install -r requirements.txt

- name: Run full attack simulation
- name: Run full attack simulation (self-contained scenarios + Docker stack)
run: python tools/run_attack_simulation.py
123 changes: 69 additions & 54 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,59 +1,74 @@
name: CI

on:
push:
branches: [develop, staging, main]
pull_request:
branches: [develop, staging, main]
push:
branches: [develop, staging, main]
pull_request:
branches: [develop, staging, main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
pip install -r requirements.txt

- name: Lint
run: ruff check . --output-format=github

- name: Run smoke test (direct to vulnerable server)
run: python tests/smoke_test.py

- name: Run end-to-end proxy test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_proxy_e2e.py

- name: Run rug-pull schema-change test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_rugpull_schema.py

- name: Run cascade detection test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_cascade.py
- name: Verify detection actually fired (fail build if not)
run: |
python -c "
import sqlite3
conn = sqlite3.connect('proxy/watchtower.db')
flagged_calls = conn.execute('SELECT COUNT(*) FROM calls WHERE flags IS NOT NULL').fetchone()[0]
desc_findings = conn.execute('SELECT COUNT(*) FROM description_findings').fetchone()[0]
rug_pulls = conn.execute(\"SELECT COUNT(*) FROM tool_fingerprints WHERE last_flag = 'rug_pull'\").fetchone()[0]
cascade_findings = conn.execute('SELECT COUNT(*) FROM cascade_findings').fetchone()[0]
assert flagged_calls > 0, 'expected at least one flagged call, found none'
assert desc_findings > 0, 'expected at least one description finding, found none'
assert rug_pulls > 0, 'expected at least one rug-pull detection, found none'
assert cascade_findings > 0, 'expected at least one cascade finding, found none'
print(f'OK: {flagged_calls} flagged calls, {desc_findings} description findings, {rug_pulls} rug pulls, {cascade_findings} cascade findings')
"
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
pip install -r requirements.txt

- name: Lint
run: ruff check . --output-format=github

- name: Run smoke test (self-contained, no Docker)
run: python tests/smoke_test.py

- name: Run proxy end-to-end test (self-contained, no Docker)
run: python tests/test_proxy_e2e.py

- name: Run rug-pull schema-change + reconnect test (self-contained, no Docker)
run: python tests/test_rugpull_schema.py

- name: Verify detection actually fired (fail build if not)
run: |
python -c "
import sqlite3
conn = sqlite3.connect('proxy/watchtower.db')
flagged_calls = conn.execute('SELECT COUNT(*) FROM calls WHERE flags IS NOT NULL').fetchone()[0]
desc_findings = conn.execute('SELECT COUNT(*) FROM description_findings').fetchone()[0]
rug_pulls = conn.execute(\"SELECT COUNT(*) FROM tool_fingerprints WHERE last_flag = 'rug_pull'\").fetchone()[0]
assert flagged_calls > 0, 'expected at least one flagged call, found none'
assert desc_findings > 0, 'expected at least one description finding, found none'
assert rug_pulls > 0, 'expected at least one rug-pull detection, found none'
print(f'OK: {flagged_calls} flagged calls, {desc_findings} description findings, {rug_pulls} rug pulls')
"

- name: Bring up full Docker Compose stack
run: docker compose up --build -d

- name: Wait for proxy to be reachable
run: |
for i in $(seq 1 30); do
if curl -s -o /dev/null http://localhost:8000/mcp; then
echo "proxy is up"
break
fi
echo "waiting for proxy... ($i/30)"
sleep 1
done

- name: Run full Docker stack test
run: python tests/test_docker_stack.py

- name: Dump proxy logs (always, useful for debugging failures)
if: always()
run: docker compose logs proxy

- name: Tear down Docker Compose stack
if: always()
run: docker compose down
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Single shared image for all three Watchtower services (proxy, filesrv,
# mailsrv). They have identical Python dependencies, so one image built
# once and reused with a different CMD per service is simpler to maintain
# than three near-identical Dockerfiles, and keeps the dependency layer
# cached across all three in Compose.

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY vulnerable-server/ ./vulnerable-server/
COPY lab-server-b/ ./lab-server-b/
COPY proxy/ ./proxy/
53 changes: 53 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Brings up the whole Watchtower stack with one command: filesrv, mailsrv,
# and the proxy, all talking to each other over a private Docker network
# using service names as hostnames (Compose's built-in DNS) instead of
# localhost/127.0.0.1, which only works because they're on the same
# machine today.
#
# Usage:
# docker compose up --build
# docker compose down (stop everything)
# docker compose logs -f proxy (tail just the proxy's logs)

services:
filesrv:
build: .
command: python vulnerable-server/server.py
environment:
HOST: "0.0.0.0"
PORT: "8001"
WATCHTOWER_VARIAT: "clean"
networks:
- watchtower-net

mailsrv:
build: .
command: python lab-server-b/mailserver.py
environment:
HOST: "0.0.0.0"
PORT: "8002"
networks:
- watchtower-net

proxy:
build: .
command: python proxy/proxy.py
environment:
HOST: "0.0.0.0"
PORT: "8000"
WATCHTOWER_DB_PATH: "/app/data/watchtower.db"
ports:
- "8000:8000"
volumes:
- watchtower-db:/app/data
depends_on:
- filesrv
- mailsrv
networks:
- watchtower-net

networks:
watchtower-net:

volumes:
watchtower-db:
28 changes: 13 additions & 15 deletions lab-server-b/mailserver.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
"""
Watchtower Lab Target B: "internal mail server" MCP server.

Paired with vulnerable-server/server.py (which plays the role of a "file
server" with a secret-returning tool) to give us a cross-server
boundary to test cascade detection against: something reads a secret from
one server, something else sends it out via a completely different server.

Nothing here is malicious on its own. The risk only exists in the combination:
secret-from-server-A landing in the body of a call to server-B.
Watchtower Lab Target B: a simple "internal mail server" MCP server.
Runs over streamable-http so it can live in its own container,
independently of the proxy and filesrv.
"""

import os

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("watchtower-labl-target-b")
mcp = FastMCP(
"watchtower-lab-target-b",
host=os.environ.get("HOST", "127.0.0.1"),
port=int(os.environ.get("PORT", "8002")),
)

@mcp.tool()
def send_email(to: str, subject: str, body: str) -> str:
"""
Send an email to the given address with the given subject and body.
"""
return f"Email sent to {to} (subject: {subject!r}, {len(body)} chars in body)."
"""Send an email on behalf of the user."""
return f"Email sent to {to} (subejct: {subject!r}, {len(body)} chars in body)."

if __name__ == "__main__":
mcp.run(transport="stdio")
mcp.run(transport="streamable-http")
Loading
Loading