mcp-security-proxy
README.md
# MCP Security Proxy
A personal, on-demand red/blue/purple-team lab project. It logs every tool call an
AI agent makes through an MCP proxy, baselines each agent's normal behavior, flags
anomalies, runs a lab-scoped red-team simulation against that baseline, scores
blue's detection rate, and narrates the whole run to Telegram.
Not a daemon. Nothing here runs 24/7 — it's triggered by a CLI command or a cron
job, does one run, and exits. Targets are restricted to self-owned lab VMs only,
never external systems.
## Why
Most anomaly detection assumes the actor being profiled is a human on a network —
peers, off-hours logins, transfer volume. This project applies the same behavioral
baselining technique to a different actor: an AI agent's MCP tool calls. An agent
that suddenly calls a tool it's never touched, at an hour it's never active, with a
payload far outside its normal size, or with a burst of distinct tools in one
window looks a lot like lateral movement looks on a network — so the same detection
approach generalizes to it.
It's built on top of a prior project, `lateral-movement-detector`, which validated
this baselining approach on real captured network traffic (per-device peer/hours/
volume/fan-out baselines, 4 signal types, 5/5 detection on a simulated SSH-flood
attack with 0 false positives). This project ports that same detector logic
(`baseline.py` / `detector.py`) onto a new data source — an MCP call log instead of
a traffic capture — rather than starting the detection approach from scratch.
## Architecture
```
AI agent (MCP client)
│
▼
┌─────────────────┐ forwards every call ┌────────────────────┐
│ proxy/server │ ──────────────────────────▶ │ real downstream MCP │
│ (stdio MCP │ ◀────────────────────────── │ server │
│ proxy) │ returns result └────────────────────┘
└─────────────────┘
│ writes one hash-chained entry per call
▼
logs/calls.jsonl (proxy/audit_log.py — append-only, tamper-evident)
│
▼
┌───────────────────┐ per-agent stats ┌──────────────────┐
│ detector/baseline │ ───────────────────▶ │ detector/baseline │
│ (learns normal) │ │ .json │
└───────────────────┘ └──────────────────┘
│ │
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ detector/detector — compares new calls to the baseline, │
│ raises NEW_TOOL / OFF_PATTERN / PAYLOAD_OUTLIER / │
│ SEQUENCE_SPIKE / UNKNOWN_AGENT, tiers by signal count │
└───────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ detector/suppression — checks config/known_jobs.yaml; │
│ strips signals a documented recurring job explains │
│ (never silently — suppressed signals stay visible in output) │
└───────────────────────────────────────────────────────────┘
│
▼ tier: alert / observation / suppressed
┌───────────────────┐ ┌───────────────────┐
│ red/orchestrator │ scope │ config/lab_scope │
│ picks technique + │◀──────▶│ .yaml (hard │
│ target, runs it via│ │ allowlist, fail- │
│ red/executor │ │ closed, exact- │
│ (Mock, or Caldera │ │ match only) │
│ via --executor) │ └───────────────────┘
└───────────────────┘
│ injects the result as an MCP call under an
│ EXISTING baselined agent identity
▼
purple/orchestrator — runs red, injects into logs/calls.jsonl,
runs detector.detect() once over the updated log, reads back
each round's outcome by trace_id, scores alert/observation/
suppressed/missed, logs to logs/purple_runs.jsonl
│
▼
trigger/run.py — the on-demand entrypoint (CLI or cron). Drives
the same red+blue round-trip and narrates each stage via
trigger/telegram.py: run started → technique attempted →
detected/observation/suppressed/missed → containment
recommendation (alert tier only, trigger/containment.py) →
run summary
│
▼
Telegram chat
```
Every log (`logs/calls.jsonl`, `logs/red_runs.jsonl`, `logs/purple_runs.jsonl`) is
append-only and hash-chained: each entry embeds the SHA-256 hash of the previous
entry, so editing or deleting a prior line breaks the chain for everything after
it. `proxy/audit_log.py` and `red/run_log.py` / `purple/scoreboard.py` are
independent implementations of the same pattern rather than shared code, on
purpose — different domain fields, low duplication cost, avoids coupling logs that
evolve separately.
## Components
| Package | Role |
|------------|------|
| `proxy/` | Stdio MCP proxy. Sits between the agent and a real downstream MCP server, forwards every call, writes a hash-chained audit entry (`trace_id`, UTC timestamp, `agent_id`, `tool_name`, `target_resource`, `payload_size`, `reasoning_summary`) for each one. |
| `detector/`| Learns a per-agent baseline (known tools, active hours, avg/std payload size, avg/std fan-out per time window) from the call log, then flags calls that deviate. Single signal → `observation` (logged only); 2+ correlated signals → `alert`. |
| `detector/suppression.py` | Checks anomalies against `config/known_jobs.yaml` — documented recurring legitimate patterns (agent + tool + hour window + payload range). Matched signals become `suppressed`, not silently dropped — the output still shows what was suppressed and by which job. |
| `red/` | Lab-scoped red-team simulation. `scope.py` is a fail-closed allowlist loader (missing/empty/wildcard scope = no targets, ever); `executor.py`'s base class re-checks scope itself before any technique runs, so even a bug in the orchestrator can't get a live action dispatched out-of-scope; `MockExecutor` just reports what it would have done. |
| `purple/` | Runs a red-team round, injects the result into the *same* `logs/calls.jsonl` under an already-baselined agent identity (modeling "attacker compromised a trusted identity," not "attacker is a new identity"), runs the detector over the updated log, and scores the round as alert/observation/suppressed/missed. |
| `trigger/` | The on-demand entrypoint (`trigger/run.py`) meant for a manual command or a cron job. Drives one purple round-trip and narrates each stage to Telegram (`trigger/telegram.py`, falls back to stdout if no bot token/chat ID is configured). `trigger/containment.py` produces a plain-text *recommendation* for alert-tier detections only — never a real action. |
## Running it
Requires Python 3.12 and the packages in `requirements.txt` (a venv is expected —
see `PROJECT_SPEC.md`'s environment notes).
```bash
pip install -r requirements.txt
```
**1. Generate some call traffic** through the proxy (dev smoke test against the
bundled mock downstream server):
```bash
python3 -m proxy.test_client
```
This drives `proxy/server.py` (configured via `config/proxy_config.yaml`), which
appends entries to `logs/calls.jsonl`. Run it a number of times (or point a real
MCP agent at the proxy) to build up enough history for a baseline.
**2. Build the baseline:**
```bash
python3 -m detector.baseline logs/calls.jsonl
```
Writes `detector/baseline.json`.
**3. Run the detector standalone:**
```bash
python3 -m detector.detector logs/calls.jsonl
```
Prints alerts / observations / suppressed calls against `config/known_jobs.yaml`.
**4. Run a red-team simulation on its own** (mock executor, targets restricted to
`config/lab_scope.yaml`):
```bash
python3 -m red.orchestrator --runs 3
```
**5. Run red + blue together and score detection:**
```bash
python3 -m purple.orchestrator --rounds 5 --impersonate-agent dev-test-agent
```
Requires a baseline that already contains `--impersonate-agent`'s identity (step 2
must have run against call history for that agent first).
**6. Run the full on-demand pipeline with Telegram notifications:**
```bash
cp .env.example .env # fill in TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID, or leave blank
python3 -m trigger.run --rounds 5 --impersonate-agent dev-test-agent
```
Without Telegram credentials set, notifications print to stdout instead — the
pipeline runs the same either way. For scheduled runs, add a cron entry, e.g.:
```
0 3 * * * cd /path/to/mcp-security-proxy && venv/bin/python3 -m trigger.run
```
## Known limitations
Being upfront about where this project currently overstates or hasn't yet proven
itself:
- **Live Caldera integration exists, but mock is still the default.**
`red/executor.py` now includes `CalderaExecutor` alongside `MockExecutor`,
launching real MITRE Caldera operations (Discovery adversary, atomic planner)
against a deployed agent — `red/orchestrator.py --executor caldera` opts in
(`MockExecutor` runs by default). This has been verified end-to-end: a real
operation against a real deployed agent, with actual `whoami`/`passwd` output
captured back through the same scope-gated path as the mock. The known
constraint, flagged directly in `CalderaExecutor`'s docstring: Caldera targets
an agent *group*, not an individual host, and this lab's scope
(`config/lab_scope.yaml`) has exactly one host mapped to one fixed group — if
the scope ever grows a second host on a different agent, `CalderaExecutor`
needs a real target→group lookup, which doesn't exist yet.
- **`target_resource` and `reasoning_summary` are best-effort heuristics, not
ground truth.** MCP tool-call arguments are arbitrary JSON with no first-class
"what resource is this touching" or "why" field. `proxy/extract.py` scans a
fixed list of common argument key names (`path`, `url`, `host`, `command`, etc.)
for `target_resource`, falling back to the first argument key/value if none
match. `reasoning_summary` only reflects real agent intent if the calling agent
populates the non-standard `_meta.reasoning` field this proxy defines; otherwise
it's a generated stub listing the argument keys. Both are documented in the code
as best-effort — the detector doesn't (and shouldn't) trust either one blindly.
- **Business-logic judgment isn't solved.** `config/known_jobs.yaml` suppression
only handles patterns a human has explicitly documented in advance (specific
agent + tool + hour window + payload range). It does not, and isn't intended to,
make any general judgment about whether a novel action is legitimate — an
unmatched anomaly is always left for human review, never auto-cleared.
- **Detection has only been exercised against deliberately obvious anomalies.**
Verification so far (per project history) has been things like a brand-new tool
name, an off-hours call, or a payload far outside normal range — synthetic and
clearly outside the baseline. It has not been tested against a subtler or
evasive adversary deliberately trying to blend into an agent's normal
tool-call pattern (e.g. staying inside known tools/hours/payload ranges while
still doing something malicious, or slowly drifting a baseline over many
legitimate-looking calls before acting). The z-score/count-based signals here
are not adversarially hardened.
- **Phase 7's `tools_jsd` drift threshold (0.15) is now calibrated, not guessed.**
`detector/test_tools_drift_synthetic.py` adds the test the other three
thresholds already had: a synthetic agent whose read_file/list_dir usage mix
drifts from ~95%/5% to a near-total reversal (~7%/93%) over four weeks, while
staying inside known tools (no `NEW_TOOL`), a fixed hour set (no `OFF_PATTERN`),
and a tool-independent payload distribution (no `PAYLOAD_OUTLIER`, and no
payload/fan-out/hours contribution to `BASELINE_DRIFT` — the mix shift is
isolated on purpose). A stable-weeks sanity check (two training weeks, both
~95/5 with ordinary day-to-day noise) measures the real `tools_jsd` noise floor
at **~0.001** — roughly 150x below the 0.15 threshold, a much wider margin than
`hours_jsd` has (0.4 vs. a measured floor of ~0.286, ~1.4x), because a skewed
2-tool mix is inherently far less noisy week-to-week than a 24-bucket
hour-of-day histogram. Confirmed 0.15 as-is rather than lowering it: it stays
silent through the early, still-ambiguous part of the drift (68.7%/31.3% mix →
`tools_jsd`=0.094) and fires once the composition has genuinely inverted past
its midpoint (49.7%/50.3% → 0.211), rising monotonically to 0.682 at full
reversal — the two failure modes (false-positiving on ordinary variation,
missing a real composition change) both check out for this scenario. Caveat:
the measured floor is specific to a skewed two-tool baseline like the one
tested; an agent with a more balanced multi-tool mix (e.g. three tools each
used ~30–40% of the time) hasn't been measured and could plausibly have a
higher natural noise floor — that generalization remains untested.
- Other explicit non-goals carried over from `PROJECT_SPEC.md`: no
least-privilege/access-control enforcement (`trigger/containment.py` only ever
produces a human-facing recommendation, never a real action), and this isn't
positioned as a competitor to commercial autonomous pentesting tools (XBOW,
Pentera, etc.) — it's a smaller, specialized portfolio project.
## Safety
`red/scope.py` is the single source of truth for what the red-team engine is
allowed to target (`config/lab_scope.yaml`). It fails closed — a missing, empty,
or wildcard/CIDR scope file yields *no* in-scope targets, never "allow everything"
— and matching is exact-string only. `red/executor.py`'s base class reloads and
re-checks this file itself before any technique runs, independent of whatever the
orchestrator computed, so a planning bug upstream can't get a live action
dispatched to something not on the list. This is process-level, convention-based
scoping, not network-level enforcement — it stops this codebase from acting
out-of-scope, but it is not a substitute for real firewall/segmentation around the
lab VM(s) it targets.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues