Skip to content

Commit b6dc5ad

Browse files
Dumbrisclaude
andauthored
fix(docker): wrap docker CLI invocations in user shell to inherit PATH (#378)
* fix(docker): ensure Docker executions use correct PATH via shell wrapping * fix(upstream): remove unused os/exec import in monitoring.go After migrating monitoring.go to use c.newDockerCmd, the os/exec import was left dangling and broke the build. Remove it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(shellwrap): add shared shell wrapping + docker path cache package Introduces internal/shellwrap to centralize the login-shell wrapping logic and docker binary resolution that were previously duplicated (or reinvented) across internal/upstream/core and internal/security/scanner. Key helpers: * Shellescape — POSIX single-quote / cmd.exe quoting. * WrapWithUserShell — wraps a command in $SHELL -l -c for Unix (and /c for Windows cmd), honoring $SHELL instead of hardcoding sh so zsh/fish users get their real interactive PATH. * ResolveDockerPath — sync.Once-cached absolute path for the docker binary. Tries exec.LookPath first and only falls back to a one-shot login-shell probe when the fast path fails. Callers can then exec docker directly on every subsequent call, avoiding the cost of respawning a login shell on hot paths (health check, diagnostics, ~every 2-5s). * MinimalEnv — returns an allow-listed PATH+HOME environment for subprocesses that must not inherit ambient secrets. Includes unit tests covering: * Shellescape round-trips for spaces, single quotes, $, backticks, and glob stars. * WrapWithUserShell honors $SHELL overrides and falls back to /bin/bash when unset. * ResolveDockerPath is cached across calls (second call returns the same value even after PATH is sabotaged). * MinimalEnv strips AWS/GitHub/Anthropic/OpenAI credentials while retaining PATH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(scanner): resolve docker directly and drop ambient env inheritance The previous implementation of the scanner's getDockerCmd had three problems flagged in PR review: 1. It hardcoded \`sh -l -c\` on Unix, ignoring \$SHELL. Users whose login shell is zsh/fish would get a different PATH than the one they see in Terminal. 2. It hardcoded \`cmd /c\` on Windows, breaking Git Bash / WSL users, and used strings.Trim(arg, "\"") which silently mangles legitimately quoted arguments. 3. It inherited the full user environment via sh -l -c. Because the scanner runs untrusted container images (vulnerability scanners, SBOM generators, …), ambient secrets such as AWS_ACCESS_KEY_ID, GITHUB_TOKEN, or ANTHROPIC_API_KEY could leak into scan sandboxes. Fix: * Use shellwrap.ResolveDockerPath to locate the docker binary once (cached process-wide) and exec it directly — no shell wrapping. * Set cmd.Env to shellwrap.MinimalEnv(), which retains only the PATH + HOME (+ Windows equivalents) needed for docker to run. * Drop the custom quoting logic entirely; exec.Command already passes arguments verbatim when we skip the shell. Added TestDockerRunnerDoesNotLeakAmbientSecrets which pollutes the process env with fake AWS/GitHub/Anthropic credentials and then asserts cmd.Env is non-nil and does NOT contain any of those keys while still carrying PATH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf(upstream): cache docker path and delegate shell wrapping to shellwrap The Docker PATH fix in this PR made every newDockerCmd call re-spawn sh -l -c so that docker would be discoverable when mcpproxy is launched from Launchpad / a LaunchAgent. That works, but checkDockerContainerHealth and GetConnectionDiagnostics fire every few seconds each, which means we were re-reading .zshrc / .bashrc dozens of times a minute per Docker upstream. Fix: * Resolve the docker binary once via shellwrap.ResolveDockerPath (sync.Once) and exec it directly on subsequent calls. Retain a fallback to the existing c.wrapWithUserShell path in case the cache resolution fails (e.g. uncommon install layouts), so the original "works when launched from Launchpad" guarantee stands. Also deduplicate the shell wrapping implementation: * c.wrapWithUserShell is now a thin wrapper around shellwrap.WrapWithUserShell. It still emits the server-scoped debug log line that existed before for log continuity. * The package-local shellescape helper is kept as an alias of shellwrap.Shellescape for backward compatibility with the existing TestShellescape suite in shell_test.go. No behavior change for the personal edition binary beyond the perf improvement — docker commands are still launched with the correct PATH and the secure environment built by c.envManager. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 4dad6d1 commit b6dc5ad

File tree

7 files changed

+479
-106
lines changed

7 files changed

+479
-106
lines changed

internal/security/scanner/docker.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,23 @@ import (
1010
"strings"
1111
"time"
1212

13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
1314
"go.uber.org/zap"
1415
)
1516

16-
// DockerRunner executes Docker operations for scanner containers
17+
// DockerRunner executes Docker operations for scanner containers.
18+
//
19+
// Security note: the scanner runs untrusted container images (vulnerability
20+
// scanners, SBOM generators, etc). It MUST NOT inherit the user's ambient
21+
// credentials (AWS, GitHub, Anthropic tokens, …). We therefore:
22+
// 1. Resolve the docker binary once via shellwrap.ResolveDockerPath — this
23+
// picks up Homebrew / Docker Desktop / Colima installs even when mcpproxy
24+
// was launched from a GUI with a minimal PATH, WITHOUT re-spawning a
25+
// login shell on every invocation (important for hot paths like the
26+
// ~2s health check loop).
27+
// 2. Invoke docker directly with cmd.Env set to a minimal PATH+HOME
28+
// allowlist via shellwrap.MinimalEnv, so environment-based secrets do
29+
// not leak into the docker CLI's subprocess tree.
1730
type DockerRunner struct {
1831
logger *zap.Logger
1932
}
@@ -23,9 +36,30 @@ func NewDockerRunner(logger *zap.Logger) *DockerRunner {
2336
return &DockerRunner{logger: logger}
2437
}
2538

39+
// getDockerCmd creates an exec.Cmd for Docker with a minimal, allow-listed
40+
// environment so ambient secrets (AWS creds, GitHub tokens, …) cannot leak
41+
// into the security scanner's subprocess. The docker binary is resolved once
42+
// via shellwrap and then cached for the process lifetime.
43+
func (d *DockerRunner) getDockerCmd(ctx context.Context, args ...string) *exec.Cmd {
44+
dockerBin, err := shellwrap.ResolveDockerPath(d.logger)
45+
if err != nil || dockerBin == "" {
46+
// Fall back to plain "docker" so exec returns a clear ENOENT the
47+
// caller can surface. We still set the minimal env to avoid leaking
48+
// secrets even in the error path.
49+
dockerBin = "docker"
50+
if d.logger != nil {
51+
d.logger.Debug("scanner docker: falling back to bare 'docker' lookup",
52+
zap.Error(err))
53+
}
54+
}
55+
cmd := exec.CommandContext(ctx, dockerBin, args...)
56+
cmd.Env = shellwrap.MinimalEnv()
57+
return cmd
58+
}
59+
2660
// IsDockerAvailable checks if Docker daemon is running
2761
func (d *DockerRunner) IsDockerAvailable(ctx context.Context) bool {
28-
cmd := exec.CommandContext(ctx, "docker", "info")
62+
cmd := d.getDockerCmd(ctx, "info")
2963
cmd.Stdout = nil
3064
cmd.Stderr = nil
3165
return cmd.Run() == nil
@@ -34,7 +68,7 @@ func (d *DockerRunner) IsDockerAvailable(ctx context.Context) bool {
3468
// PullImage pulls a Docker image with progress logging
3569
func (d *DockerRunner) PullImage(ctx context.Context, image string) error {
3670
d.logger.Info("Pulling Docker image", zap.String("image", image))
37-
cmd := exec.CommandContext(ctx, "docker", "pull", image)
71+
cmd := d.getDockerCmd(ctx, "pull", image)
3872
var stderr bytes.Buffer
3973
cmd.Stderr = &stderr
4074
if err := cmd.Run(); err != nil {
@@ -46,15 +80,15 @@ func (d *DockerRunner) PullImage(ctx context.Context, image string) error {
4680

4781
// ImageExists checks if a Docker image exists locally
4882
func (d *DockerRunner) ImageExists(ctx context.Context, image string) bool {
49-
cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image)
83+
cmd := d.getDockerCmd(ctx, "image", "inspect", image)
5084
cmd.Stdout = nil
5185
cmd.Stderr = nil
5286
return cmd.Run() == nil
5387
}
5488

5589
// RemoveImage removes a Docker image
5690
func (d *DockerRunner) RemoveImage(ctx context.Context, image string) error {
57-
cmd := exec.CommandContext(ctx, "docker", "rmi", "-f", image)
91+
cmd := d.getDockerCmd(ctx, "rmi", "-f", image)
5892
var stderr bytes.Buffer
5993
cmd.Stderr = &stderr
6094
if err := cmd.Run(); err != nil {
@@ -156,7 +190,7 @@ func (d *DockerRunner) RunScanner(ctx context.Context, cfg ScannerRunConfig) (st
156190
defer cancel()
157191
}
158192

159-
cmd := exec.CommandContext(runCtx, "docker", args...)
193+
cmd := d.getDockerCmd(runCtx, args...)
160194
var stdoutBuf, stderrBuf bytes.Buffer
161195
cmd.Stdout = &stdoutBuf
162196
cmd.Stderr = &stderrBuf
@@ -186,7 +220,7 @@ func (d *DockerRunner) KillContainer(ctx context.Context, name string) error {
186220
if name == "" {
187221
return nil
188222
}
189-
cmd := exec.CommandContext(ctx, "docker", "kill", name)
223+
cmd := d.getDockerCmd(ctx, "kill", name)
190224
cmd.Stdout = nil
191225
cmd.Stderr = nil
192226
return cmd.Run()
@@ -197,7 +231,7 @@ func (d *DockerRunner) StopContainer(ctx context.Context, name string, timeout i
197231
if name == "" {
198232
return nil
199233
}
200-
cmd := exec.CommandContext(ctx, "docker", "stop", "-t", fmt.Sprintf("%d", timeout), name)
234+
cmd := d.getDockerCmd(ctx, "stop", "-t", fmt.Sprintf("%d", timeout), name)
201235
cmd.Stdout = nil
202236
cmd.Stderr = nil
203237
return cmd.Run()
@@ -224,7 +258,7 @@ func (d *DockerRunner) ReadReportFile(reportDir string) ([]byte, error) {
224258

225259
// GetImageDigest returns the Docker image digest
226260
func (d *DockerRunner) GetImageDigest(ctx context.Context, image string) (string, error) {
227-
cmd := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.Id}}", image)
261+
cmd := d.getDockerCmd(ctx, "inspect", "--format", "{{.Id}}", image)
228262
var stdout bytes.Buffer
229263
cmd.Stdout = &stdout
230264
if err := cmd.Run(); err != nil {

internal/security/scanner/docker_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,42 @@ func TestStopContainerEmptyName(t *testing.T) {
225225
t.Errorf("StopContainer with empty name should return nil, got: %v", err)
226226
}
227227
}
228+
229+
// TestDockerRunnerDoesNotLeakAmbientSecrets verifies that the scanner's
230+
// Docker subprocess is invoked with a minimal, allow-listed environment so
231+
// credentials in the parent process environment (AWS, GitHub, Anthropic,
232+
// …) cannot leak into containers that run untrusted scanner images.
233+
func TestDockerRunnerDoesNotLeakAmbientSecrets(t *testing.T) {
234+
t.Setenv("AWS_ACCESS_KEY_ID", "AKIA_test_dummy_value_00000000")
235+
t.Setenv("GITHUB_TOKEN", "ghp_test_dummy_token_1234567890abcdef")
236+
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test-dummy-not-real")
237+
238+
logger := zap.NewNop()
239+
d := NewDockerRunner(logger)
240+
241+
cmd := d.getDockerCmd(context.Background(), "version")
242+
243+
if cmd.Env == nil {
244+
t.Fatal("cmd.Env must be explicitly set (non-nil) so it does not inherit os.Environ()")
245+
}
246+
247+
joined := strings.Join(cmd.Env, "\n")
248+
forbidden := []string{"AWS_ACCESS_KEY_ID", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"}
249+
for _, key := range forbidden {
250+
if strings.Contains(joined, key) {
251+
t.Errorf("scanner docker command leaked %q into cmd.Env", key)
252+
}
253+
}
254+
255+
// PATH must still be present so the docker binary is actually runnable.
256+
hasPath := false
257+
for _, kv := range cmd.Env {
258+
if strings.HasPrefix(kv, "PATH=") {
259+
hasPath = true
260+
break
261+
}
262+
}
263+
if !hasPath {
264+
t.Error("scanner docker command must retain PATH so 'docker' is discoverable")
265+
}
266+
}

internal/shellwrap/shellwrap.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Package shellwrap provides platform-level helpers for wrapping commands in
2+
// the user's login shell and for resolving tool binaries (e.g. docker) with
3+
// PATH caching.
4+
//
5+
// It exists so that both the upstream proxy code (internal/upstream/core) and
6+
// the security scanner (internal/security/scanner) can share a single,
7+
// well-tested implementation of shell quoting + login-shell wrapping instead
8+
// of each rolling their own.
9+
package shellwrap
10+
11+
import (
12+
"context"
13+
"fmt"
14+
"os"
15+
"os/exec"
16+
"runtime"
17+
"strings"
18+
"sync"
19+
"time"
20+
21+
"go.uber.org/zap"
22+
)
23+
24+
const (
25+
osWindows = "windows"
26+
// defaultUnixShell is used when $SHELL is unset on a Unix-like system.
27+
defaultUnixShell = "/bin/bash"
28+
// defaultWindowsShell is used when neither $ComSpec nor $SHELL is set.
29+
defaultWindowsShell = "cmd"
30+
)
31+
32+
// Shellescape escapes a single argument for safe inclusion in a shell command
33+
// string. On Unix it uses POSIX single-quoting; on Windows it performs a
34+
// best-effort cmd.exe quoting.
35+
//
36+
// This mirrors the implementation in internal/upstream/core so both code paths
37+
// can converge on one function.
38+
func Shellescape(s string) string {
39+
if s == "" {
40+
if runtime.GOOS == osWindows {
41+
return `""`
42+
}
43+
return "''"
44+
}
45+
46+
if runtime.GOOS == osWindows {
47+
// Windows cmd.exe special characters.
48+
if !strings.ContainsAny(s, " \t\n\r\"&|<>()^%") {
49+
return s
50+
}
51+
// cmd.exe does not use backslash as an escape character. If the
52+
// caller supplied embedded double quotes we strip them — this is
53+
// the same behaviour the upstream helper has used since PR #195.
54+
cleaned := strings.Trim(s, `"`)
55+
return `"` + cleaned + `"`
56+
}
57+
58+
// Unix shell special characters: if none present, return as-is.
59+
if !strings.ContainsAny(s, " \t\n\r\"'\\$`;&|<>(){}[]?*~") {
60+
return s
61+
}
62+
// Use single quotes and escape any embedded single quotes.
63+
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
64+
}
65+
66+
// isBashLikeShell mirrors the detection logic in connection_stdio.go so that
67+
// Git Bash / MSYS on Windows uses the Unix-style -l -c flags.
68+
func isBashLikeShell(shell string) bool {
69+
lower := strings.ToLower(shell)
70+
return strings.Contains(lower, "bash") || strings.Contains(lower, "sh")
71+
}
72+
73+
// resolveLoginShell returns the user's preferred login shell, respecting the
74+
// $SHELL environment variable and falling back to platform defaults.
75+
func resolveLoginShell() string {
76+
shell := os.Getenv("SHELL")
77+
if shell != "" {
78+
return shell
79+
}
80+
if runtime.GOOS == osWindows {
81+
if cs := os.Getenv("ComSpec"); cs != "" {
82+
return cs
83+
}
84+
return defaultWindowsShell
85+
}
86+
return defaultUnixShell
87+
}
88+
89+
// WrapWithUserShell wraps a command and its arguments in the user's login
90+
// shell so the child process inherits the interactive PATH (important when
91+
// mcpproxy is launched from a GUI / LaunchAgent on macOS).
92+
//
93+
// It returns the shell to exec and the shell arguments (e.g. ["-l", "-c",
94+
// "docker run ..."] on Unix, ["/c", "docker run ..."] on Windows cmd).
95+
//
96+
// logger may be nil; when non-nil a debug line is emitted mirroring the
97+
// existing upstream/core helper.
98+
func WrapWithUserShell(logger *zap.Logger, command string, args []string) (shell string, shellArgs []string) {
99+
shell = resolveLoginShell()
100+
101+
parts := make([]string, 0, len(args)+1)
102+
parts = append(parts, Shellescape(command))
103+
for _, a := range args {
104+
parts = append(parts, Shellescape(a))
105+
}
106+
commandString := strings.Join(parts, " ")
107+
108+
if logger != nil {
109+
logger.Debug("shellwrap: wrapping command with user login shell",
110+
zap.String("original_command", command),
111+
zap.Strings("original_args", args),
112+
zap.String("shell", shell),
113+
zap.String("wrapped_command", commandString))
114+
}
115+
116+
isBash := isBashLikeShell(shell)
117+
if runtime.GOOS == osWindows && !isBash {
118+
// Windows cmd.exe: /c to execute a command string.
119+
return shell, []string{"/c", commandString}
120+
}
121+
// Unix shells and Git Bash on Windows: -l for login env, -c for command.
122+
return shell, []string{"-l", "-c", commandString}
123+
}
124+
125+
// --- Docker path resolution ---------------------------------------------
126+
127+
var (
128+
dockerPathOnce sync.Once
129+
dockerPath string
130+
dockerPathErr error
131+
)
132+
133+
// ResolveDockerPath returns the absolute path to the `docker` binary. The
134+
// result is cached for the process lifetime so that repeated calls from hot
135+
// paths (health checks, connection diagnostics) do not re-spawn a login shell
136+
// on every invocation.
137+
//
138+
// Resolution order:
139+
// 1. exec.LookPath("docker") — cheap, works when mcpproxy was started from
140+
// a terminal or when the LaunchAgent PATH already contains docker.
141+
// 2. Fallback: ask the user's login shell `command -v docker` so we pick up
142+
// Homebrew / Colima / Docker Desktop installs that only exist in the
143+
// interactive PATH. This fallback is only run once.
144+
func ResolveDockerPath(logger *zap.Logger) (string, error) {
145+
dockerPathOnce.Do(func() {
146+
// Fast path: ask Go's standard PATH lookup first.
147+
if p, err := exec.LookPath("docker"); err == nil && p != "" {
148+
dockerPath = p
149+
if logger != nil {
150+
logger.Debug("shellwrap: resolved docker via PATH", zap.String("path", p))
151+
}
152+
return
153+
}
154+
155+
// Slow path: shell out once via the user's login shell.
156+
if runtime.GOOS == osWindows {
157+
dockerPathErr = fmt.Errorf("docker not found in PATH")
158+
return
159+
}
160+
161+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162+
defer cancel()
163+
164+
shell, shellArgs := WrapWithUserShell(logger, "command", []string{"-v", "docker"})
165+
cmd := exec.CommandContext(ctx, shell, shellArgs...)
166+
out, err := cmd.Output()
167+
if err != nil {
168+
dockerPathErr = fmt.Errorf("login-shell docker lookup failed: %w", err)
169+
return
170+
}
171+
resolved := strings.TrimSpace(string(out))
172+
if resolved == "" {
173+
dockerPathErr = fmt.Errorf("docker not found in login shell PATH")
174+
return
175+
}
176+
dockerPath = resolved
177+
if logger != nil {
178+
logger.Debug("shellwrap: resolved docker via login shell",
179+
zap.String("path", resolved))
180+
}
181+
})
182+
return dockerPath, dockerPathErr
183+
}
184+
185+
// resetDockerPathCacheForTest is used by tests to probe the sync.Once
186+
// behaviour. It is intentionally unexported and only referenced from
187+
// shellwrap_test.go.
188+
func resetDockerPathCacheForTest() {
189+
dockerPathOnce = sync.Once{}
190+
dockerPath = ""
191+
dockerPathErr = nil
192+
}
193+
194+
// --- Minimal environment for scanner subprocesses ------------------------
195+
196+
// MinimalEnv returns a minimal, allow-listed environment suitable for
197+
// subprocesses that must NOT inherit the user's ambient credentials (e.g.
198+
// AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc). It includes PATH + HOME on Unix and
199+
// PATH + USERPROFILE on Windows so that `docker` itself still functions.
200+
//
201+
// Callers that need TLS or Docker-specific variables (DOCKER_HOST,
202+
// DOCKER_CONFIG, …) should append them explicitly.
203+
func MinimalEnv() []string {
204+
env := make([]string, 0, 8)
205+
if v := os.Getenv("PATH"); v != "" {
206+
env = append(env, "PATH="+v)
207+
}
208+
if runtime.GOOS == osWindows {
209+
if v := os.Getenv("USERPROFILE"); v != "" {
210+
env = append(env, "USERPROFILE="+v)
211+
}
212+
if v := os.Getenv("SystemRoot"); v != "" {
213+
env = append(env, "SystemRoot="+v)
214+
}
215+
if v := os.Getenv("ComSpec"); v != "" {
216+
env = append(env, "ComSpec="+v)
217+
}
218+
} else {
219+
if v := os.Getenv("HOME"); v != "" {
220+
env = append(env, "HOME="+v)
221+
}
222+
}
223+
return env
224+
}

0 commit comments

Comments
 (0)