Skip to content

Commit 93d9b58

Browse files
authored
fix(scanner): wire ramparts v0.8.x URL/stdio scanning via static replay shim (#670)
Ramparts v0.8.x dropped file/directory scanning: `ramparts scan <target>` now requires a live MCP endpoint (URL or `stdio:`), and `scan`'s --format no longer accepts `sarif` nor a `--output` flag. The container's entrypoint still ran the stale `--format sarif --output FILE /scan/source`, so every scan failed even after the image was unblocked (MCP-2395/#665). Bridge the source-tree model to the new endpoint model without ever executing the untrusted upstream (preserves the no-exec invariant, MCP-2206/#658): - mcp-replay.py: a static MCP stdio server that replays the tool definitions MCPProxy already exports into /scan/source/tools.json (the same file the Cisco scanner consumes). entrypoint runs `ramparts scan stdio:python3:.../mcp-replay.py --format json`. Unit-tested against the rmcp handshake (initialize/tools-list/ resources/prompts/ping/notifications/unknown-method/missing-file). - Dockerfile: pin ramparts 0.8.2, ship its YARA `rules/`, `taxonomies/` and `config.yaml` (loaded relative to CWD — a binary-only image loads zero rules and finds nothing), and add a python3 runtime for the shim. - engine.go: the rampartsIssue parser was also stale — v0.8.x serializes `issue_type`/`severity`/`message`/`details` (+ per-kind subject), not the old `type`/`impact`, so security_issues parsed with empty rule IDs and wrong severities. Updated to the v0.8.x shape; the yara_results path was already compatible. Parser proven end-to-end with a faithful v0.8.2 `--format json` fixture. - registry: NetworkReq false (replay shim is local, YARA offline); refreshed description/comments. - docs: refresh the ramparts design + the now-fixed/amd64-only arm64 note. Full container E2E (built amd64 image vs a poisoned server) runs in CI/QA. Related MCP-2422
1 parent b29a265 commit 93d9b58

10 files changed

Lines changed: 548 additions & 32 deletions

File tree

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,65 @@
1-
# Ramparts MCP Scanner — Rust-based YARA scanner for MCP servers.
1+
# Ramparts MCP Scanner — Rust-based YARA scanner for MCP servers (v0.8.x).
22
#
33
# Upstream: https://github.com/getjavelin/ramparts
44
# Crate: https://crates.io/crates/ramparts
55
#
66
# Published as: ghcr.io/smart-mcp-proxy/scanner-ramparts:latest
77
#
8-
# We use a two-stage build: a Rust builder installs the `ramparts` crate
9-
# and the final image ships a slim Debian with just the binary.
8+
# Two-stage build: a Rust builder installs the `ramparts` crate and clones the
9+
# matching source tag; the final slim image ships the binary, its runtime
10+
# assets, and a static MCP stdio replay shim.
11+
#
12+
# Why we ship more than the binary (v0.8.x):
13+
# - Ramparts loads its YARA rules, taxonomies, and default config from the
14+
# working directory at runtime (./rules, ./taxonomies, ./config.yaml).
15+
# `cargo install` only produces the binary, so without these the scanner
16+
# loads ZERO rules and reports no findings. We copy them from the source
17+
# tag that matches the installed crate version.
18+
# - v0.8.x scans a live MCP endpoint, not a directory. MCPProxy never
19+
# re-executes the untrusted upstream; instead the entrypoint points Ramparts
20+
# at `stdio:python3:/usr/local/bin/mcp-replay.py`, a static shim that replays
21+
# the already-captured /scan/source/tools.json over the MCP protocol. That
22+
# needs a python3 runtime in the final image.
1023
#
1124
# IMPORTANT: the builder's Debian release MUST match the runtime stage
1225
# (`debian:bookworm-slim`, glibc 2.36). The unpinned `rust:1-slim` tag now
13-
# resolves to Debian trixie (glibc 2.39+), so the binary it produces fails
14-
# at runtime on bookworm with `GLIBC_2.39 not found` (MCP-2395). Pinning the
26+
# resolves to Debian trixie (glibc 2.39+), so the binary it produces fails at
27+
# runtime on bookworm with `GLIBC_2.39 not found` (MCP-2395). Pinning the
1528
# builder to `-bookworm` keeps both stages on the same glibc.
29+
ARG RAMPARTS_VERSION=0.8.2
30+
1631
FROM rust:1-slim-bookworm AS builder
32+
ARG RAMPARTS_VERSION
1733
RUN apt-get update && apt-get install -y --no-install-recommends \
1834
pkg-config libssl-dev ca-certificates git && \
1935
rm -rf /var/lib/apt/lists/*
20-
RUN cargo install ramparts --locked --root /opt/ramparts
36+
RUN cargo install ramparts --version "${RAMPARTS_VERSION}" --locked --root /opt/ramparts
37+
# Clone the SAME version tag to obtain the runtime assets (rules/taxonomies/
38+
# config) that the installed binary expects to find relative to its CWD.
39+
RUN git clone --depth 1 --branch "${RAMPARTS_VERSION}" \
40+
https://github.com/getjavelin/ramparts /opt/ramparts-src
2141

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

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

3152
COPY --from=builder /opt/ramparts/bin/ramparts /usr/local/bin/ramparts
53+
# Runtime assets loaded relative to WORKDIR (/scan). The /scan/source and
54+
# /scan/report bind mounts attach as subdirectories at runtime and do not
55+
# shadow these baked-in files.
56+
COPY --from=builder /opt/ramparts-src/rules /scan/rules
57+
COPY --from=builder /opt/ramparts-src/taxonomies /scan/taxonomies
58+
COPY --from=builder /opt/ramparts-src/config.yaml /scan/config.yaml
59+
3260
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
33-
RUN chmod +x /usr/local/bin/entrypoint.sh
61+
COPY mcp-replay.py /usr/local/bin/mcp-replay.py
62+
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/mcp-replay.py
3463

3564
WORKDIR /scan
3665
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
#!/bin/sh
2-
# Entrypoint for the Ramparts scanner container.
2+
# Entrypoint for the Ramparts scanner container (v0.8.x — URL/stdio model).
3+
#
4+
# Ramparts v0.8.x dropped file/directory scanning. `ramparts scan <target>` now
5+
# expects a LIVE MCP endpoint (an HTTP URL or a `stdio:` subprocess): it runs the
6+
# MCP handshake, enumerates the advertised tools/resources/prompts, and analyzes
7+
# them with YARA rules (+ optional LLM). The old `--format sarif --output FILE
8+
# /scan/source` invocation is invalid in v0.8.x (no `sarif` format, no `--output`
9+
# flag, and a directory is not a valid scan target).
10+
#
11+
# MCPProxy must NOT re-execute the untrusted upstream just to give Ramparts a
12+
# target (that would violate the "never execute scanned source" invariant,
13+
# MCP-2206/#658). Instead the engine exports the tool definitions it already
14+
# captured from the running upstream into /scan/source/tools.json (the same file
15+
# the Cisco scanner consumes), and we replay them to Ramparts over stdio via
16+
# mcp-replay.py — a static shim that runs no upstream code.
317
#
418
# MCPProxy mounts:
5-
# /scan/source — read-only, contains the server source tree.
6-
# /scan/report — writable, scanner writes SARIF here.
7-
set -eu
8-
exec ramparts scan \
9-
--format sarif \
10-
--output /scan/report/results.sarif \
11-
/scan/source
19+
# /scan/source — read-only; contains tools.json (captured tool definitions).
20+
# /scan/report — writable; we write the scanner's native JSON report here.
21+
#
22+
# Ramparts loads its YARA rules (./rules), taxonomies (./taxonomies) and default
23+
# config (./config.yaml) relative to the working directory — the image ships
24+
# them at /scan and WORKDIR is /scan (see Dockerfile).
25+
set -u
26+
27+
REPORT=/scan/report/results.json
28+
29+
# `scan`'s --format accepts json|raw|table|text (no sarif) and has no --output
30+
# flag, so emit native JSON to stdout and capture it. MCPProxy's engine parses
31+
# the Ramparts JSON shape ({security_issues{...}, yara_results[]}) directly.
32+
# A non-zero exit from findings/offline-LLM is expected and tolerated by the
33+
# engine as long as a report was produced, so don't let `set -e` abort here.
34+
ramparts scan "stdio:python3:/usr/local/bin/mcp-replay.py" \
35+
--format json \
36+
> "$REPORT" 2>/scan/report/ramparts.stderr || true
37+
38+
if [ ! -s "$REPORT" ]; then
39+
echo "ramparts produced no report; stderr follows:" >&2
40+
cat /scan/report/ramparts.stderr >&2 2>/dev/null || true
41+
exit 1
42+
fi
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""Static MCP stdio "replay" server for the Ramparts scanner.
3+
4+
Ramparts v0.8.x is a *dynamic* scanner: it connects to a live MCP endpoint
5+
(HTTP URL or `stdio:` subprocess), performs the MCP handshake, enumerates the
6+
server's tools/resources/prompts, and runs its YARA rules + (optional) LLM
7+
analysis on what the server advertises. It no longer scans a source directory.
8+
9+
MCPProxy's scanner framework, by contrast, mounts a read-only *source tree* at
10+
`/scan/source` and — critically — also exports the tool definitions it already
11+
captured from the running upstream into `/scan/source/tools.json` (the same file
12+
the Cisco scanner consumes). We must NOT execute the untrusted upstream a second
13+
time just to give Ramparts something to connect to (that would violate the
14+
"never execute scanned package source" invariant, MCP-2206/#658).
15+
16+
This shim bridges the gap safely: it speaks just enough of the MCP stdio
17+
protocol (newline-delimited JSON-RPC 2.0) to *replay* the already-captured tool
18+
definitions to Ramparts. No upstream code runs; we only re-serve static JSON.
19+
20+
Ramparts launches us as `stdio:python3:/usr/local/bin/mcp-replay.py`, so this
21+
file is invoked with the captured-tools path defaulting to /scan/source/tools.json
22+
(overridable via $RAMPARTS_REPLAY_TOOLS for tests).
23+
"""
24+
import json
25+
import os
26+
import sys
27+
28+
# MCP protocol revision Ramparts' rmcp client initializes with (src/mcp_client.rs).
29+
PROTOCOL_VERSION = "2025-06-18"
30+
TOOLS_PATH = os.environ.get("RAMPARTS_REPLAY_TOOLS", "/scan/source/tools.json")
31+
32+
33+
def log(msg):
34+
"""Diagnostics go to stderr; stdout is reserved for JSON-RPC frames only."""
35+
print(f"[mcp-replay] {msg}", file=sys.stderr, flush=True)
36+
37+
38+
def load_tools():
39+
"""Read the captured tool definitions. Returns a list of MCP tool objects.
40+
41+
Tolerant of a missing/empty/garbled file: an empty tool list still lets the
42+
handshake complete so Ramparts emits a (clean) report instead of a connect
43+
error.
44+
"""
45+
try:
46+
with open(TOOLS_PATH, "r", encoding="utf-8") as fh:
47+
data = json.load(fh)
48+
except (OSError, ValueError) as exc:
49+
log(f"could not read {TOOLS_PATH}: {exc}; serving 0 tools")
50+
return []
51+
52+
raw = data.get("tools", []) if isinstance(data, dict) else []
53+
tools = []
54+
for entry in raw:
55+
if not isinstance(entry, dict) or not entry.get("name"):
56+
continue
57+
tool = {
58+
"name": entry["name"],
59+
"description": entry.get("description", "") or "",
60+
# MCP requires an inputSchema object; synthesize a permissive one
61+
# when the captured definition omitted it.
62+
"inputSchema": entry.get("inputSchema") or {"type": "object"},
63+
}
64+
if entry.get("annotations"):
65+
tool["annotations"] = entry["annotations"]
66+
tools.append(tool)
67+
return tools
68+
69+
70+
def write_frame(obj):
71+
sys.stdout.write(json.dumps(obj) + "\n")
72+
sys.stdout.flush()
73+
74+
75+
def reply(req_id, result):
76+
write_frame({"jsonrpc": "2.0", "id": req_id, "result": result})
77+
78+
79+
def reply_error(req_id, code, message):
80+
write_frame({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
81+
82+
83+
def handle(msg, tools):
84+
"""Dispatch one decoded JSON-RPC message. Returns nothing; writes replies."""
85+
method = msg.get("method")
86+
req_id = msg.get("id")
87+
88+
# Notifications (no id) never get a response.
89+
if req_id is None:
90+
return
91+
92+
if method == "initialize":
93+
reply(req_id, {
94+
"protocolVersion": PROTOCOL_VERSION,
95+
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
96+
"serverInfo": {"name": "mcpproxy-ramparts-replay", "version": "1.0.0"},
97+
})
98+
elif method == "tools/list":
99+
reply(req_id, {"tools": tools})
100+
elif method == "resources/list":
101+
reply(req_id, {"resources": []})
102+
elif method == "resources/templates/list":
103+
reply(req_id, {"resourceTemplates": []})
104+
elif method == "prompts/list":
105+
reply(req_id, {"prompts": []})
106+
elif method == "ping":
107+
reply(req_id, {})
108+
else:
109+
# Unknown request: respond per JSON-RPC so the client doesn't hang.
110+
reply_error(req_id, -32601, f"method not found: {method}")
111+
112+
113+
def main():
114+
tools = load_tools()
115+
log(f"serving {len(tools)} captured tool definition(s) from {TOOLS_PATH}")
116+
for line in sys.stdin:
117+
line = line.strip()
118+
if not line:
119+
continue
120+
try:
121+
msg = json.loads(line)
122+
except ValueError as exc:
123+
log(f"skipping non-JSON line: {exc}")
124+
continue
125+
if isinstance(msg, list): # JSON-RPC batch
126+
for item in msg:
127+
handle(item, tools)
128+
else:
129+
handle(msg, tools)
130+
log("stdin closed; exiting")
131+
return 0
132+
133+
134+
if __name__ == "__main__":
135+
sys.exit(main())

0 commit comments

Comments
 (0)