Three findings from the same end-to-end QA pass that produced #937. None is a security break — the runtime fails closed in every case — but each misleads an operator about what the scanner is actually doing.
1. MEDIUM — REST accepts any string as trust_mode, with no validation
curl -X PATCH -H "X-API-Key: ..." .../api/v1/servers/<name> -d '{"trust_mode":"yolo"}'
→ {"success": true, "message": "Server '<name>' updated successfully"}
GET /servers then echoes trust_mode: "yolo" verbatim, and it is persisted to mcp_config.json.
The runtime does fail closed — a poisoned tool change under "yolo" is correctly held, and so is one under "ScAn" — so this is not exploitable. The problem is that an operator who typos "Scan" silently gets manual behaviour while the API and UI display their typo back to them as though it were a valid mode. Someone intending scan and typing Scan would believe scanning is on.
Expected: reject with 400, or normalize and warn.
2. MEDIUM — the signature bundle is invisible to operators, and FR-019 is unimplemented
Three related parts:
FR-019 is not implemented. The bundle is go:embed-only (internal/security/scanner/tpa_bundle.go:22). There is no config field, no env override, no filesystem load and no hot-reload. FR-019 requires all of these and states the path "MUST NOT be hardcoded". Verified by grep: zero hits for any bundle-path config or env key.
The one status report never reaches a log. tpa_bundle.go:190 logs "loaded embedded TPA scanner bundle" with runnable/skipped counts, but does so via the unconfigured global zap.L(). Searching every console and file log across four instances — including one started with --log-level=debug — produced zero occurrences. Repro: start the core with --log-level=debug, run any scan, then grep -r "TPA scanner bundle\|runnable_rules" <logdir> → nothing.
No freshness signal exists anywhere. There is no bundle version or signature count in security overview, security scanners, the REST API, or the UI — and the bundle format itself carries no generated_at, expires, or equivalent. A years-stale corpus is indistinguishable from a freshly exported one, both to a human and to any automation.
The practical consequence: an operator cannot answer "which signatures is my proxy actually running, and how old are they?" by any supported means.
3. LOW — CLI and UI hide hold state in places where an operator would look
mcpproxy tools list --server <name> shows no HELD column and no approval status — only the global mcpproxy tools list carries it. Worse, the server-scoped view prints the raw poisoned description unescaped to the terminal, where the global view correctly escapes control characters.
Repro: create a held changed tool, then compare mcpproxy tools list --server <name> (no state shown) against mcpproxy tools list (HELD populated).
mcpproxy upstream list shows a green ✅ Connected (1 tool) while a poisoned tool sits held on that server.
The Web UI server card reads "Clean" — a green shield from the last full server scan — directly above the text "1 tool changed since approval — re-review needed", while the tool-level gate is holding that tool with a dangerous verdict and two TPA signature hits. The juxtaposition reads as reassurance next to a warning.
No --trust-mode flag on mcpproxy upstream add. The mode can only be set by pre-seeding config or by a follow-up REST PATCH, so a server cannot be added directly into scan mode from the CLI.
Context
The hold-evidence surfacing itself is good where it exists — the global tools list renders matched TPA ids first so truncation never drops them (FR-018 confirmed working), and the Web UI hold panel with its signature chips and before/after description diff is genuinely useful. These findings are about the places that state does not reach.
Three findings from the same end-to-end QA pass that produced #937. None is a security break — the runtime fails closed in every case — but each misleads an operator about what the scanner is actually doing.
1. MEDIUM — REST accepts any string as
trust_mode, with no validationGET /serversthen echoestrust_mode: "yolo"verbatim, and it is persisted tomcp_config.json.The runtime does fail closed — a poisoned tool change under
"yolo"is correctly held, and so is one under"ScAn"— so this is not exploitable. The problem is that an operator who typos"Scan"silently getsmanualbehaviour while the API and UI display their typo back to them as though it were a valid mode. Someone intendingscanand typingScanwould believe scanning is on.Expected: reject with 400, or normalize and warn.
2. MEDIUM — the signature bundle is invisible to operators, and FR-019 is unimplemented
Three related parts:
FR-019 is not implemented. The bundle is
go:embed-only (internal/security/scanner/tpa_bundle.go:22). There is no config field, no env override, no filesystem load and no hot-reload. FR-019 requires all of these and states the path "MUST NOT be hardcoded". Verified by grep: zero hits for any bundle-path config or env key.The one status report never reaches a log.
tpa_bundle.go:190logs"loaded embedded TPA scanner bundle"with runnable/skipped counts, but does so via the unconfigured globalzap.L(). Searching every console and file log across four instances — including one started with--log-level=debug— produced zero occurrences. Repro: start the core with--log-level=debug, run any scan, thengrep -r "TPA scanner bundle\|runnable_rules" <logdir>→ nothing.No freshness signal exists anywhere. There is no bundle version or signature count in
security overview,security scanners, the REST API, or the UI — and the bundle format itself carries nogenerated_at,expires, or equivalent. A years-stale corpus is indistinguishable from a freshly exported one, both to a human and to any automation.The practical consequence: an operator cannot answer "which signatures is my proxy actually running, and how old are they?" by any supported means.
3. LOW — CLI and UI hide hold state in places where an operator would look
mcpproxy tools list --server <name>shows no HELD column and no approval status — only the globalmcpproxy tools listcarries it. Worse, the server-scoped view prints the raw poisoned description unescaped to the terminal, where the global view correctly escapes control characters.Repro: create a held
changedtool, then comparemcpproxy tools list --server <name>(no state shown) againstmcpproxy tools list(HELD populated).mcpproxy upstream listshows a green✅ Connected (1 tool)while a poisoned tool sits held on that server.The Web UI server card reads "Clean" — a green shield from the last full server scan — directly above the text "1 tool changed since approval — re-review needed", while the tool-level gate is holding that tool with a
dangerousverdict and two TPA signature hits. The juxtaposition reads as reassurance next to a warning.No
--trust-modeflag onmcpproxy upstream add. The mode can only be set by pre-seeding config or by a follow-up REST PATCH, so a server cannot be added directly intoscanmode from the CLI.Context
The hold-evidence surfacing itself is good where it exists — the global
tools listrenders matched TPA ids first so truncation never drops them (FR-018 confirmed working), and the Web UI hold panel with its signature chips and before/after description diff is genuinely useful. These findings are about the places that state does not reach.