Skip to content
Merged
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
45 changes: 37 additions & 8 deletions docker/scanners/ramparts/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,36 +1,65 @@
# Ramparts MCP Scanner — Rust-based YARA scanner for MCP servers.
# Ramparts MCP Scanner — Rust-based YARA scanner for MCP servers (v0.8.x).
#
# Upstream: https://github.com/getjavelin/ramparts
# Crate: https://crates.io/crates/ramparts
#
# Published as: ghcr.io/smart-mcp-proxy/scanner-ramparts:latest
#
# We use a two-stage build: a Rust builder installs the `ramparts` crate
# and the final image ships a slim Debian with just the binary.
# Two-stage build: a Rust builder installs the `ramparts` crate and clones the
# matching source tag; the final slim image ships the binary, its runtime
# assets, and a static MCP stdio replay shim.
#
# Why we ship more than the binary (v0.8.x):
# - Ramparts loads its YARA rules, taxonomies, and default config from the
# working directory at runtime (./rules, ./taxonomies, ./config.yaml).
# `cargo install` only produces the binary, so without these the scanner
# loads ZERO rules and reports no findings. We copy them from the source
# tag that matches the installed crate version.
# - v0.8.x scans a live MCP endpoint, not a directory. MCPProxy never
# re-executes the untrusted upstream; instead the entrypoint points Ramparts
# at `stdio:python3:/usr/local/bin/mcp-replay.py`, a static shim that replays
# the already-captured /scan/source/tools.json over the MCP protocol. That
# needs a python3 runtime in the final image.
#
# IMPORTANT: the builder's Debian release MUST match the runtime stage
# (`debian:bookworm-slim`, glibc 2.36). The unpinned `rust:1-slim` tag now
# resolves to Debian trixie (glibc 2.39+), so the binary it produces fails
# at runtime on bookworm with `GLIBC_2.39 not found` (MCP-2395). Pinning the
# resolves to Debian trixie (glibc 2.39+), so the binary it produces fails at
# runtime on bookworm with `GLIBC_2.39 not found` (MCP-2395). Pinning the
# builder to `-bookworm` keeps both stages on the same glibc.
ARG RAMPARTS_VERSION=0.8.2

FROM rust:1-slim-bookworm AS builder
ARG RAMPARTS_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev ca-certificates git && \
rm -rf /var/lib/apt/lists/*
RUN cargo install ramparts --locked --root /opt/ramparts
RUN cargo install ramparts --version "${RAMPARTS_VERSION}" --locked --root /opt/ramparts
# Clone the SAME version tag to obtain the runtime assets (rules/taxonomies/
# config) that the installed binary expects to find relative to its CWD.
RUN git clone --depth 1 --branch "${RAMPARTS_VERSION}" \
https://github.com/getjavelin/ramparts /opt/ramparts-src

FROM debian:bookworm-slim
LABEL org.opencontainers.image.source="https://github.com/smart-mcp-proxy/mcpproxy-go"
LABEL org.opencontainers.image.description="Ramparts MCP Scanner packaged for MCPProxy"
LABEL org.opencontainers.image.licenses="Proprietary"

# python3 powers the static MCP stdio replay shim (mcp-replay.py); no pip needed.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && \
ca-certificates libssl3 python3 && \
rm -rf /var/lib/apt/lists/*

COPY --from=builder /opt/ramparts/bin/ramparts /usr/local/bin/ramparts
# Runtime assets loaded relative to WORKDIR (/scan). The /scan/source and
# /scan/report bind mounts attach as subdirectories at runtime and do not
# shadow these baked-in files.
COPY --from=builder /opt/ramparts-src/rules /scan/rules
COPY --from=builder /opt/ramparts-src/taxonomies /scan/taxonomies
COPY --from=builder /opt/ramparts-src/config.yaml /scan/config.yaml

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
COPY mcp-replay.py /usr/local/bin/mcp-replay.py
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/mcp-replay.py

WORKDIR /scan
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
47 changes: 39 additions & 8 deletions docker/scanners/ramparts/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
#!/bin/sh
# Entrypoint for the Ramparts scanner container.
# Entrypoint for the Ramparts scanner container (v0.8.x — URL/stdio model).
#
# Ramparts v0.8.x dropped file/directory scanning. `ramparts scan <target>` now
# expects a LIVE MCP endpoint (an HTTP URL or a `stdio:` subprocess): it runs the
# MCP handshake, enumerates the advertised tools/resources/prompts, and analyzes
# them with YARA rules (+ optional LLM). The old `--format sarif --output FILE
# /scan/source` invocation is invalid in v0.8.x (no `sarif` format, no `--output`
# flag, and a directory is not a valid scan target).
#
# MCPProxy must NOT re-execute the untrusted upstream just to give Ramparts a
# target (that would violate the "never execute scanned source" invariant,
# MCP-2206/#658). Instead the engine exports the tool definitions it already
# captured from the running upstream into /scan/source/tools.json (the same file
# the Cisco scanner consumes), and we replay them to Ramparts over stdio via
# mcp-replay.py — a static shim that runs no upstream code.
#
# MCPProxy mounts:
# /scan/source — read-only, contains the server source tree.
# /scan/report — writable, scanner writes SARIF here.
set -eu
exec ramparts scan \
--format sarif \
--output /scan/report/results.sarif \
/scan/source
# /scan/source — read-only; contains tools.json (captured tool definitions).
# /scan/report — writable; we write the scanner's native JSON report here.
#
# Ramparts loads its YARA rules (./rules), taxonomies (./taxonomies) and default
# config (./config.yaml) relative to the working directory — the image ships
# them at /scan and WORKDIR is /scan (see Dockerfile).
set -u

REPORT=/scan/report/results.json

# `scan`'s --format accepts json|raw|table|text (no sarif) and has no --output
# flag, so emit native JSON to stdout and capture it. MCPProxy's engine parses
# the Ramparts JSON shape ({security_issues{...}, yara_results[]}) directly.
# A non-zero exit from findings/offline-LLM is expected and tolerated by the
# engine as long as a report was produced, so don't let `set -e` abort here.
ramparts scan "stdio:python3:/usr/local/bin/mcp-replay.py" \
--format json \
> "$REPORT" 2>/scan/report/ramparts.stderr || true

if [ ! -s "$REPORT" ]; then
echo "ramparts produced no report; stderr follows:" >&2
cat /scan/report/ramparts.stderr >&2 2>/dev/null || true
exit 1
fi
135 changes: 135 additions & 0 deletions docker/scanners/ramparts/mcp-replay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Static MCP stdio "replay" server for the Ramparts scanner.

Ramparts v0.8.x is a *dynamic* scanner: it connects to a live MCP endpoint
(HTTP URL or `stdio:` subprocess), performs the MCP handshake, enumerates the
server's tools/resources/prompts, and runs its YARA rules + (optional) LLM
analysis on what the server advertises. It no longer scans a source directory.

MCPProxy's scanner framework, by contrast, mounts a read-only *source tree* at
`/scan/source` and — critically — also exports the tool definitions it already
captured from the running upstream into `/scan/source/tools.json` (the same file
the Cisco scanner consumes). We must NOT execute the untrusted upstream a second
time just to give Ramparts something to connect to (that would violate the
"never execute scanned package source" invariant, MCP-2206/#658).

This shim bridges the gap safely: it speaks just enough of the MCP stdio
protocol (newline-delimited JSON-RPC 2.0) to *replay* the already-captured tool
definitions to Ramparts. No upstream code runs; we only re-serve static JSON.

Ramparts launches us as `stdio:python3:/usr/local/bin/mcp-replay.py`, so this
file is invoked with the captured-tools path defaulting to /scan/source/tools.json
(overridable via $RAMPARTS_REPLAY_TOOLS for tests).
"""
import json
import os
import sys

# MCP protocol revision Ramparts' rmcp client initializes with (src/mcp_client.rs).
PROTOCOL_VERSION = "2025-06-18"
TOOLS_PATH = os.environ.get("RAMPARTS_REPLAY_TOOLS", "/scan/source/tools.json")


def log(msg):
"""Diagnostics go to stderr; stdout is reserved for JSON-RPC frames only."""
print(f"[mcp-replay] {msg}", file=sys.stderr, flush=True)


def load_tools():
"""Read the captured tool definitions. Returns a list of MCP tool objects.

Tolerant of a missing/empty/garbled file: an empty tool list still lets the
handshake complete so Ramparts emits a (clean) report instead of a connect
error.
"""
try:
with open(TOOLS_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError) as exc:
log(f"could not read {TOOLS_PATH}: {exc}; serving 0 tools")
return []

raw = data.get("tools", []) if isinstance(data, dict) else []
tools = []
for entry in raw:
if not isinstance(entry, dict) or not entry.get("name"):
continue
tool = {
"name": entry["name"],
"description": entry.get("description", "") or "",
# MCP requires an inputSchema object; synthesize a permissive one
# when the captured definition omitted it.
"inputSchema": entry.get("inputSchema") or {"type": "object"},
}
if entry.get("annotations"):
tool["annotations"] = entry["annotations"]
tools.append(tool)
return tools


def write_frame(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()


def reply(req_id, result):
write_frame({"jsonrpc": "2.0", "id": req_id, "result": result})


def reply_error(req_id, code, message):
write_frame({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})


def handle(msg, tools):
"""Dispatch one decoded JSON-RPC message. Returns nothing; writes replies."""
method = msg.get("method")
req_id = msg.get("id")

# Notifications (no id) never get a response.
if req_id is None:
return

if method == "initialize":
reply(req_id, {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
"serverInfo": {"name": "mcpproxy-ramparts-replay", "version": "1.0.0"},
})
elif method == "tools/list":
reply(req_id, {"tools": tools})
elif method == "resources/list":
reply(req_id, {"resources": []})
elif method == "resources/templates/list":
reply(req_id, {"resourceTemplates": []})
elif method == "prompts/list":
reply(req_id, {"prompts": []})
elif method == "ping":
reply(req_id, {})
else:
# Unknown request: respond per JSON-RPC so the client doesn't hang.
reply_error(req_id, -32601, f"method not found: {method}")


def main():
tools = load_tools()
log(f"serving {len(tools)} captured tool definition(s) from {TOOLS_PATH}")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except ValueError as exc:
log(f"skipping non-JSON line: {exc}")
continue
if isinstance(msg, list): # JSON-RPC batch
for item in msg:
handle(item, tools)
else:
handle(msg, tools)
log("stdin closed; exiting")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading