Skip to content

Commit eba4a85

Browse files
authored
fix(security): redact upstream secrets in docker spawn argv debug logs (#713)
At logging.level=debug, docker-command upstreams injected Slack/Jira tokens as `-e KEY=VALUE` flags that several argv/command log sites rendered in cleartext to main.log. Masking was inconsistent — only the shellwrap line was partially handled. Add a shared redaction helper in internal/shellwrap that masks the VALUE of docker env flags (`-e`/`--env`) while leaving keys, flags, and image names intact. It covers the two-token form (`-e`, `KEY=VALUE`), the glued single-token form (`-eKEY=VALUE`), and the embedded command-string form (the `-c` argument of a login-shell wrap), reusing secret.MaskSecretValue. Apply it at every argv/command debug log site in the spawn path: shellwrap.WrapWithUserShell (wrapped_command + original_args), connection_stdio.go (original_args/modified_args/final_args), and the process-group CommandFunc on unix + windows. Related #712
1 parent 9442824 commit eba4a85

7 files changed

Lines changed: 279 additions & 9 deletions

File tree

internal/shellwrap/redact.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package shellwrap
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
8+
)
9+
10+
// dockerEnvFlagRegex matches a docker env flag (`-e` / `--env`) followed by a
11+
// whitespace-separated value token within a command STRING. The value token is
12+
// either single/double-quoted (it may then contain spaces) or a run of
13+
// non-whitespace characters. Group 1 anchors the flag to a word boundary
14+
// (start-of-string or preceding whitespace) so it does not match inside other
15+
// flags or image names.
16+
var dockerEnvFlagRegex = regexp.MustCompile(`(^|\s)(--env|-e)(\s+)('[^']*'|"[^"]*"|\S+)`)
17+
18+
// RedactDockerArgs returns a copy of a docker-style argv slice with the VALUE
19+
// of every `KEY=VALUE` env injection masked, leaving keys, flags, image names,
20+
// and other args untouched. It handles three shapes the spawn path produces:
21+
//
22+
// - two-token form: ["-e", "KEY=VALUE"] (direct-exec docker)
23+
// - glued single tok: ["-eKEY=VALUE"] / ["--env=KEY=VALUE"]
24+
// - command-string el: ["-l", "-c", "docker run -e KEY=VALUE img"] (shell wrap)
25+
//
26+
// The input slice is never mutated. A nil input returns nil.
27+
func RedactDockerArgs(args []string) []string {
28+
if args == nil {
29+
return nil
30+
}
31+
out := make([]string, 0, len(args))
32+
for i := 0; i < len(args); i++ {
33+
a := args[i]
34+
// Two-token form: `-e`/`--env` followed by `KEY=VALUE`.
35+
if (a == "-e" || a == "--env") && i+1 < len(args) {
36+
out = append(out, a, maskEnvToken(args[i+1]))
37+
i++
38+
continue
39+
}
40+
out = append(out, redactArgElement(a))
41+
}
42+
return out
43+
}
44+
45+
// redactArgElement masks env secrets inside a single argv element, covering the
46+
// glued single-token forms and the embedded command-string form.
47+
func redactArgElement(a string) string {
48+
// Command-string element (e.g. the `-c` argument of a login-shell wrap):
49+
// mask any embedded `-e KEY=VALUE` occurrences.
50+
if strings.ContainsAny(a, " \t") {
51+
return RedactDockerCommandString(a)
52+
}
53+
// Glued long form: `--env=KEY=VALUE`.
54+
if rest, ok := strings.CutPrefix(a, "--env="); ok {
55+
return "--env=" + maskEnvToken(rest)
56+
}
57+
// Glued short form: `-eKEY=VALUE`.
58+
if strings.HasPrefix(a, "-e") && len(a) > 2 && strings.Contains(a[2:], "=") {
59+
return "-e" + maskEnvToken(a[2:])
60+
}
61+
return a
62+
}
63+
64+
// RedactDockerCommandString masks the VALUE of any `-e KEY=VALUE` /
65+
// `--env KEY=VALUE` flag found within a single command string, leaving the rest
66+
// of the string (subcommand, flags, image name) intact.
67+
func RedactDockerCommandString(s string) string {
68+
return dockerEnvFlagRegex.ReplaceAllStringFunc(s, func(m string) string {
69+
sub := dockerEnvFlagRegex.FindStringSubmatch(m)
70+
// sub[1]=lead, sub[2]=flag, sub[3]=whitespace, sub[4]=value token
71+
return sub[1] + sub[2] + sub[3] + maskEnvToken(sub[4])
72+
})
73+
}
74+
75+
// maskEnvToken masks the value portion of a `KEY=VALUE` token, preserving the
76+
// key and any surrounding single/double quotes. Tokens without a `=` or with an
77+
// empty value are returned unchanged (they are not secret-bearing env values).
78+
func maskEnvToken(tok string) string {
79+
quote := ""
80+
inner := tok
81+
if len(inner) >= 2 {
82+
if (inner[0] == '\'' && inner[len(inner)-1] == '\'') ||
83+
(inner[0] == '"' && inner[len(inner)-1] == '"') {
84+
quote = string(inner[0])
85+
inner = inner[1 : len(inner)-1]
86+
}
87+
}
88+
eq := strings.IndexByte(inner, '=')
89+
if eq < 0 {
90+
return tok
91+
}
92+
key := inner[:eq]
93+
val := inner[eq+1:]
94+
if val == "" {
95+
return tok
96+
}
97+
return quote + key + "=" + secret.MaskSecretValue(val) + quote
98+
}

internal/shellwrap/redact_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package shellwrap
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"go.uber.org/zap"
9+
"go.uber.org/zap/zapcore"
10+
"go.uber.org/zap/zaptest/observer"
11+
)
12+
13+
const (
14+
secretValueSlack = "xoxc-abc123456789"
15+
secretValueJira = "xoxd-zzz987654321"
16+
)
17+
18+
// assertNoSecret fails if s contains any of the cleartext secret values that
19+
// must never reach a log.
20+
func assertNoSecret(t *testing.T, s string) {
21+
t.Helper()
22+
for _, sv := range []string{secretValueSlack, secretValueJira} {
23+
if strings.Contains(s, sv) {
24+
t.Errorf("cleartext secret %q leaked in %q", sv, s)
25+
}
26+
}
27+
}
28+
29+
func TestRedactDockerArgs_TwoTokenForm(t *testing.T) {
30+
in := []string{"run", "--rm", "-i", "-e", "SLACK_TOKEN=" + secretValueSlack, "myimage:latest"}
31+
out := RedactDockerArgs(in)
32+
33+
joined := strings.Join(out, " ")
34+
assertNoSecret(t, joined)
35+
36+
// Non-secret args untouched.
37+
for _, want := range []string{"run", "--rm", "-i", "myimage:latest"} {
38+
if !contains(out, want) {
39+
t.Errorf("expected non-secret arg %q to be preserved, got %v", want, out)
40+
}
41+
}
42+
// Key and flag preserved.
43+
if !strings.Contains(joined, "-e") || !strings.Contains(joined, "SLACK_TOKEN=") {
44+
t.Errorf("expected -e and key SLACK_TOKEN= preserved, got %q", joined)
45+
}
46+
// Input slice must not be mutated.
47+
if in[4] != "SLACK_TOKEN="+secretValueSlack {
48+
t.Errorf("RedactDockerArgs mutated its input slice: %q", in[4])
49+
}
50+
}
51+
52+
func TestRedactDockerArgs_SingleGluedToken(t *testing.T) {
53+
in := []string{"run", "-eSLACK_TOKEN=" + secretValueSlack, "myimage"}
54+
out := RedactDockerArgs(in)
55+
joined := strings.Join(out, " ")
56+
assertNoSecret(t, joined)
57+
if !strings.Contains(joined, "SLACK_TOKEN=") {
58+
t.Errorf("expected key preserved, got %q", joined)
59+
}
60+
}
61+
62+
func TestRedactDockerArgs_CommandStringElement(t *testing.T) {
63+
in := []string{"-l", "-c", "docker run --rm -e SLACK_TOKEN=" + secretValueSlack + " -e JIRA_TOKEN=" + secretValueJira + " myimage:latest"}
64+
out := RedactDockerArgs(in)
65+
joined := strings.Join(out, " ")
66+
assertNoSecret(t, joined)
67+
for _, want := range []string{"-l", "docker run", "--rm", "myimage:latest", "SLACK_TOKEN=", "JIRA_TOKEN="} {
68+
if !strings.Contains(joined, want) {
69+
t.Errorf("expected %q preserved in %q", want, joined)
70+
}
71+
}
72+
}
73+
74+
func TestRedactDockerCommandString(t *testing.T) {
75+
in := "docker run --rm -e SLACK_TOKEN=" + secretValueSlack + " --env JIRA_TOKEN=" + secretValueJira + " myimage"
76+
out := RedactDockerCommandString(in)
77+
assertNoSecret(t, out)
78+
for _, want := range []string{"docker run", "--rm", "myimage", "SLACK_TOKEN=", "JIRA_TOKEN="} {
79+
if !strings.Contains(out, want) {
80+
t.Errorf("expected %q preserved in %q", want, out)
81+
}
82+
}
83+
}
84+
85+
func TestRedactDockerArgs_NoEnvFlagsUnchanged(t *testing.T) {
86+
in := []string{"run", "--rm", "-i", "ghcr.io/foo/bar:latest", "--config", "/etc/x.json"}
87+
out := RedactDockerArgs(in)
88+
if strings.Join(in, " ") != strings.Join(out, " ") {
89+
t.Errorf("non-env args were altered: in=%v out=%v", in, out)
90+
}
91+
}
92+
93+
// Guard test: the WrapWithUserShell debug log must not emit cleartext secrets
94+
// in its wrapped_command / original_args fields.
95+
func TestWrapWithUserShell_LogsAreRedacted(t *testing.T) {
96+
obsCore, recorded := observer.New(zapcore.DebugLevel)
97+
logger := zap.New(obsCore)
98+
99+
WrapWithUserShell(logger, "docker", []string{"run", "--rm", "-e", "SLACK_TOKEN=" + secretValueSlack, "myimage"})
100+
101+
if recorded.Len() == 0 {
102+
t.Fatal("expected a debug log entry from WrapWithUserShell")
103+
}
104+
for _, entry := range recorded.All() {
105+
for k, v := range entry.ContextMap() {
106+
assertNoSecret(t, k)
107+
assertNoSecret(t, fmt.Sprintf("%v", v))
108+
}
109+
assertNoSecret(t, entry.Message)
110+
}
111+
}
112+
113+
func contains(ss []string, want string) bool {
114+
for _, s := range ss {
115+
if s == want {
116+
return true
117+
}
118+
}
119+
return false
120+
}

internal/shellwrap/shellwrap.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,13 @@ func WrapWithUserShell(logger *zap.Logger, command string, args []string) (shell
106106
commandString := strings.Join(parts, " ")
107107

108108
if logger != nil {
109+
// Redact secret env values (`-e KEY=VALUE`) before logging — docker-command
110+
// upstreams inject Slack/Jira tokens here and debug logs are written to disk.
109111
logger.Debug("shellwrap: wrapping command with user login shell",
110112
zap.String("original_command", command),
111-
zap.Strings("original_args", args),
113+
zap.Strings("original_args", RedactDockerArgs(args)),
112114
zap.String("shell", shell),
113-
zap.String("wrapped_command", commandString))
115+
zap.String("wrapped_command", RedactDockerCommandString(commandString)))
114116
}
115117

116118
isBash := isBashLikeShell(shell)

internal/upstream/core/connection_stdio.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func (c *Client) connectStdio(ctx context.Context) error {
102102
c.logger.Debug("Docker command detected, setting up container ID tracking",
103103
zap.String("server", c.config.Name),
104104
zap.String("command", c.config.Command),
105-
zap.Strings("original_args", args))
105+
zap.Strings("original_args", shellwrap.RedactDockerArgs(args)))
106106

107107
// CRITICAL: Clean up any existing containers first to prevent duplicates
108108
// This makes container creation idempotent and safe to call multiple times
@@ -166,7 +166,7 @@ func (c *Client) connectStdio(ctx context.Context) error {
166166
c.logger.Debug("Injected env vars into direct docker command",
167167
zap.String("server", c.config.Name),
168168
zap.Int("env_count", len(c.config.Env)),
169-
zap.Strings("modified_args", argsToWrap))
169+
zap.Strings("modified_args", shellwrap.RedactDockerArgs(argsToWrap)))
170170
}
171171

172172
// Use shell wrapping for environment inheritance
@@ -205,9 +205,9 @@ func (c *Client) connectStdio(ctx context.Context) error {
205205
c.logger.Debug("Initialized stdio transport",
206206
zap.String("server", c.config.Name),
207207
zap.String("final_command", finalCommand),
208-
zap.Strings("final_args", finalArgs),
208+
zap.Strings("final_args", shellwrap.RedactDockerArgs(finalArgs)),
209209
zap.String("original_command", c.config.Command),
210-
zap.Strings("original_args", args),
210+
zap.Strings("original_args", shellwrap.RedactDockerArgs(args)),
211211
zap.String("working_dir", c.config.WorkingDir),
212212
zap.Bool("docker_isolation", c.isDockerCommand))
213213

@@ -392,7 +392,7 @@ func (c *Client) wrapWithUserShell(command string, args []string) (shellCommand
392392
c.logger.Debug("Wrapping command with user shell for full environment inheritance",
393393
zap.String("server", c.config.Name),
394394
zap.String("original_command", command),
395-
zap.Strings("original_args", args),
395+
zap.Strings("original_args", shellwrap.RedactDockerArgs(args)),
396396
zap.String("shell", shellCommand))
397397
return shellCommand, shellArgs
398398
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//go:build unix
2+
3+
package core
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"strings"
9+
"testing"
10+
11+
"go.uber.org/zap"
12+
"go.uber.org/zap/zapcore"
13+
"go.uber.org/zap/zaptest/observer"
14+
)
15+
16+
// TestProcessGroupCommandFunc_RedactsSecretArgs is a guard test for GH #712:
17+
// the "Process group configuration applied" debug log must not emit cleartext
18+
// upstream secrets passed as `-e KEY=VALUE` to docker-command upstreams.
19+
func TestProcessGroupCommandFunc_RedactsSecretArgs(t *testing.T) {
20+
const secretValue = "xoxc-abc123456789"
21+
22+
obsCore, recorded := observer.New(zapcore.DebugLevel)
23+
logger := zap.New(obsCore)
24+
25+
// client=nil keeps this off the spawn path: the CommandFunc builds the
26+
// *exec.Cmd and logs but never starts a process.
27+
fn := createProcessGroupCommandFunc(nil, "", logger)
28+
29+
cases := [][]string{
30+
// Shell-wrapped form (secret embedded in the -c command string).
31+
{"-l", "-c", "docker run --rm -e SLACK_TOKEN=" + secretValue + " myimage"},
32+
// Direct-exec form (two-token -e KEY=VALUE).
33+
{"run", "--rm", "-e", "SLACK_TOKEN=" + secretValue, "myimage"},
34+
}
35+
for _, args := range cases {
36+
if _, err := fn(context.Background(), "/bin/sh", nil, args); err != nil {
37+
t.Fatalf("CommandFunc returned error: %v", err)
38+
}
39+
}
40+
41+
for _, entry := range recorded.All() {
42+
for _, v := range entry.ContextMap() {
43+
if strings.Contains(fmt.Sprintf("%v", v), secretValue) {
44+
t.Errorf("cleartext secret leaked in log field: %v", v)
45+
}
46+
}
47+
}
48+
}

internal/upstream/core/process_unix.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"syscall"
99
"time"
1010

11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
1112
"go.uber.org/zap"
1213
)
1314

@@ -39,7 +40,7 @@ func createProcessGroupCommandFunc(client *Client, workingDir string, logger *za
3940

4041
logger.Debug("Process group configuration applied",
4142
zap.String("command", command),
42-
zap.Strings("args", args),
43+
zap.Strings("args", shellwrap.RedactDockerArgs(args)),
4344
zap.String("working_dir", workingDir))
4445

4546
if client != nil {

internal/upstream/core/process_windows.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"os/exec"
88

9+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
910
"go.uber.org/zap"
1011
)
1112

@@ -33,7 +34,7 @@ func createProcessGroupCommandFunc(client *Client, workingDir string, logger *za
3334

3435
logger.Debug("Process group configuration applied (Windows)",
3536
zap.String("command", command),
36-
zap.Strings("args", args),
37+
zap.Strings("args", shellwrap.RedactDockerArgs(args)),
3738
zap.String("working_dir", workingDir))
3839

3940
if client != nil {

0 commit comments

Comments
 (0)