diff --git a/plugins/pass/command.go b/plugins/pass/command.go index 93bb3f22..0acac88f 100644 --- a/plugins/pass/command.go +++ b/plugins/pass/command.go @@ -114,7 +114,7 @@ services: // Root returns the root command for the docker-pass CLI plugin func Root(ctx context.Context, s store.Store, info commands.VersionInfo) *cobra.Command { cmd := &cobra.Command{ - Use: "pass set|get|ls|rm", + Use: "pass set|get|ls|rm|run", Short: "Manage your local OS keychain secrets.", Long: `Docker Pass is a helper for securely storing secrets in your local OS keychain and injecting them into containers when needed. It uses platform-specific credential storage: @@ -146,6 +146,7 @@ Secrets can be injected into running containers at runtime using the se:// URI s cmd.AddCommand(wrapRunEWithSpan(commands.ListCommand(s))) cmd.AddCommand(wrapRunEWithSpan(commands.RmCommand(s))) cmd.AddCommand(wrapRunEWithSpan(commands.GetCommand(s))) + cmd.AddCommand(wrapRunEWithSpan(commands.RunCommand())) cmd.AddCommand(commands.VersionCommand(info)) return cmd diff --git a/plugins/pass/commands/run.go b/plugins/pass/commands/run.go new file mode 100644 index 00000000..dfa2dad4 --- /dev/null +++ b/plugins/pass/commands/run.go @@ -0,0 +1,154 @@ +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "strings" + + "github.com/spf13/cobra" + + "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/x/api" + "github.com/docker/secrets-engine/x/secrets" +) + +const sePrefix = "se://" + +const runExample = ` +### Run a command with one secret in its environment: +SE_TOKEN=se://gh-token docker pass run -- gh repo list + +### Multiple references: +DB_PASSWORD=se://myapp/postgres/password \ +API_KEY=se://myapp/anthropic/api-key \ + docker pass run -- ./my-binary +` + +func RunCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "run -- CMD [ARGS...]", + Short: "Run a command with se:// environment references resolved.", + Long: `Scans the current environment for variables whose value is exactly se://NAME. +Each reference is resolved through the secrets-engine daemon and the resolved +value is passed to the child process. The child inherits stdin, stdout, and +stderr. + +Requires the secrets-engine daemon (Docker Desktop) to be running. + +If any reference cannot be resolved, the command fails before the child is +started and exits non-zero.`, + Example: strings.Trim(runExample, "\n"), + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client.New(client.WithSocketPath(api.DefaultSocketPath())) + if err != nil { + return err + } + + env, err := resolveEnv(cmd.Context(), c, os.Environ()) + if err != nil { + return err + } + + // No CommandContext: the signal forwarder owns the child's + // lifecycle. Tying the child to cmd.Context() would let cobra's + // ctx cancellation SIGKILL the child out from under the forwarder. + child := exec.Command(args[0], args[1:]...) + child.Env = env + child.Stdin = os.Stdin + child.Stdout = os.Stdout + child.Stderr = os.Stderr + + if err := child.Start(); err != nil { + return err + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, forwardableSignals()...) + done := make(chan struct{}) + go func() { + for { + select { + case sig := <-sigCh: + _ = child.Process.Signal(sig) + case <-done: + return + } + } + }() + + waitErr := child.Wait() + signal.Stop(sigCh) + close(done) + + if waitErr != nil { + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + os.Exit(childExitCode(exitErr.ProcessState)) + } + return waitErr + } + return nil + }, + } + return cmd +} + +func resolveEnv(ctx context.Context, r secrets.Resolver, env []string) ([]string, error) { + out := make([]string, 0, len(env)) + for _, kv := range env { + key, value, _ := strings.Cut(kv, "=") + if !strings.HasPrefix(value, sePrefix) { + out = append(out, kv) + continue + } + resolved, err := resolveRef(ctx, r, key, value) + if err != nil { + return nil, err + } + out = append(out, key+"="+resolved) + } + return out, nil +} + +func resolveRef(ctx context.Context, r secrets.Resolver, key, value string) (string, error) { + name := strings.TrimPrefix(value, sePrefix) + // Validate as an ID first so wildcards in the reference are rejected + // instead of silently broadening the lookup. + if _, err := secrets.ParseID(name); err != nil { + return "", fmt.Errorf("resolving %s: %w", key, err) + } + pattern, err := secrets.ParsePattern(name) + if err != nil { + return "", fmt.Errorf("resolving %s: %w", key, err) + } + envs, err := r.GetSecrets(ctx, pattern) + if err != nil { + return "", fmt.Errorf("resolving %s: %w", key, err) + } + if len(envs) == 0 { + return "", fmt.Errorf("resolving %s: %w", key, secrets.ErrNotFound) + } + if len(envs) > 1 { + return "", fmt.Errorf("resolving %s: %d secrets matched %s", key, len(envs), name) + } + return string(envs[0].Value), nil +} diff --git a/plugins/pass/commands/run_test.go b/plugins/pass/commands/run_test.go new file mode 100644 index 00000000..84822779 --- /dev/null +++ b/plugins/pass/commands/run_test.go @@ -0,0 +1,265 @@ +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strconv" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/secrets-engine/x/secrets" + "github.com/docker/secrets-engine/x/testhelper" +) + +// Sentinels driving TestMain modes: +// +// helperWrapperEnv: act as a RunCommand wrapper — invoke RunCommand with +// args=[exe] so it execs this test binary again as a grandchild. +// helperActiveEnv: act as the leaf child — exit with the requested code. +const ( + helperWrapperEnv = "GO_PASS_RUN_WRAPPER" + helperActiveEnv = "GO_PASS_RUN_HELPER_ACTIVE" + helperExitEnv = "GO_PASS_RUN_HELPER_EXIT" + helperSleepEnv = "GO_PASS_RUN_HELPER_SLEEP" +) + +func TestMain(m *testing.M) { + if os.Getenv(helperWrapperEnv) != "" { + // Unset so the grandchild does not recurse into wrapper mode. + _ = os.Unsetenv(helperWrapperEnv) + runAsWrapper() + return // unreachable; runAsWrapper exits + } + if os.Getenv(helperActiveEnv) != "" { + if os.Getenv(helperSleepEnv) != "" { + // Signal-handling test: announce readiness, then block until a + // signal kills us with its default disposition (so the wrapper + // observes a signaled exit, not a normal one). + _, _ = fmt.Fprintln(os.Stderr, "READY") + select {} + } + code := 0 + if v := os.Getenv(helperExitEnv); v != "" { + if n, err := strconv.Atoi(v); err == nil { + code = n + } + } + os.Exit(code) + } + os.Exit(m.Run()) +} + +// runAsWrapper invokes RunCommand with args=[exe], so RunCommand execs the +// test binary again as a grandchild. The grandchild's exit code propagates: +// RunCommand calls os.Exit(code), so this wrapper process exits with the same +// code, which the outer test then observes via exec.ExitError. +func runAsWrapper() { + exe, err := os.Executable() + if err != nil { + os.Exit(2) + } + cmd := RunCommand() + cmd.SetArgs([]string{exe}) + cmd.SetContext(context.Background()) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestResolveEnv(t *testing.T) { + t.Parallel() + + resolver := testhelper.MockResolver{ + Store: map[secrets.ID]string{ + secrets.MustParseID("gh-token"): "ghp_abc123", + secrets.MustParseID("myapp/postgres/password"): "s3cr3t", + }, + } + + t.Run("passthrough when no se:// values", func(t *testing.T) { + in := []string{"PATH=/usr/bin", "HOME=/home/x", "EMPTY="} + out, err := resolveEnv(t.Context(), resolver, in) + require.NoError(t, err) + assert.Equal(t, in, out) + }) + + t.Run("resolves exact se:// reference", func(t *testing.T) { + in := []string{"PATH=/usr/bin", "SE_TOKEN=se://gh-token"} + out, err := resolveEnv(t.Context(), resolver, in) + require.NoError(t, err) + assert.Equal(t, []string{"PATH=/usr/bin", "SE_TOKEN=ghp_abc123"}, out) + }) + + t.Run("resolves nested ID", func(t *testing.T) { + in := []string{"PG_PWD=se://myapp/postgres/password"} + out, err := resolveEnv(t.Context(), resolver, in) + require.NoError(t, err) + assert.Equal(t, []string{"PG_PWD=s3cr3t"}, out) + }) + + t.Run("embedded se:// is left untouched", func(t *testing.T) { + in := []string{"DSN=postgres://user:se://gh-token@host/db"} + out, err := resolveEnv(t.Context(), resolver, in) + require.NoError(t, err) + assert.Equal(t, in, out) + }) + + t.Run("missing reference hard-fails", func(t *testing.T) { + in := []string{"X=se://does-not-exist"} + out, err := resolveEnv(t.Context(), resolver, in) + require.Error(t, err) + assert.Nil(t, out) + assert.Contains(t, err.Error(), "resolving X") + }) + + t.Run("invalid ID hard-fails", func(t *testing.T) { + in := []string{"X=se://"} + out, err := resolveEnv(t.Context(), resolver, in) + require.Error(t, err) + assert.Nil(t, out) + assert.Contains(t, err.Error(), "resolving X") + }) + + t.Run("wildcard in reference is rejected", func(t *testing.T) { + in := []string{"X=se://foo/*"} + out, err := resolveEnv(t.Context(), resolver, in) + require.Error(t, err) + assert.Nil(t, out) + assert.Contains(t, err.Error(), "resolving X") + }) + + t.Run("multiple refs resolved in order", func(t *testing.T) { + in := []string{ + "A=se://gh-token", + "B=plain", + "C=se://myapp/postgres/password", + } + out, err := resolveEnv(t.Context(), resolver, in) + require.NoError(t, err) + assert.Equal(t, []string{ + "A=ghp_abc123", + "B=plain", + "C=s3cr3t", + }, out) + }) +} + +// TestRunCommand covers cobra-level behavior that does not depend on a running +// daemon. Resolution behavior is covered by TestResolveEnv. +func TestRunCommand(t *testing.T) { + exe, err := os.Executable() + require.NoError(t, err) + + t.Run("no command given returns arg error", func(t *testing.T) { + cmd := RunCommand() + cmd.SetArgs([]string{}) + cmd.SetContext(t.Context()) + cmd.SetOut(testWriter{t}) + cmd.SetErr(testWriter{t}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "requires at least 1 arg") + }) + + t.Run("forwards child exit code", func(t *testing.T) { + // Spawn a wrapper subprocess that runs RunCommand internally. The + // wrapper execs a grandchild (this test binary in helper mode) that + // exits with code 42. The wrapper's env contains no se:// references, + // so RunCommand never contacts the daemon. RunCommand calls + // os.Exit(42) on the ExitError, so the wrapper process itself exits + // 42, which we observe via exec.ExitError. + sub := exec.CommandContext(t.Context(), exe) + sub.Env = append(os.Environ(), + helperWrapperEnv+"=1", + helperActiveEnv+"=1", + helperExitEnv+"=42", + ) + err := sub.Run() + var exitErr *exec.ExitError + require.True(t, errors.As(err, &exitErr), "expected ExitError, got %v", err) + assert.Equal(t, 42, exitErr.ExitCode()) + }) + + t.Run("forwards SIGINT and exits 130", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("SIGINT cross-process semantics differ on Windows") + } + + sub := exec.CommandContext(t.Context(), exe) + sub.Env = append(os.Environ(), + helperWrapperEnv+"=1", + helperActiveEnv+"=1", + helperSleepEnv+"=1", + ) + stderr, err := sub.StderrPipe() + require.NoError(t, err) + require.NoError(t, sub.Start()) + + waitForReady(t, stderr) + + require.NoError(t, sub.Process.Signal(syscall.SIGINT)) + + err = sub.Wait() + var exitErr *exec.ExitError + require.True(t, errors.As(err, &exitErr), "expected ExitError, got %v", err) + assert.Equal(t, 130, exitErr.ExitCode()) + }) +} + +func waitForReady(t *testing.T, r io.Reader) { + t.Helper() + scanner := bufio.NewScanner(r) + done := make(chan bool, 1) + go func() { + for scanner.Scan() { + if scanner.Text() == "READY" { + done <- true + return + } + } + done <- false + }() + select { + case ok := <-done: + require.True(t, ok, "subprocess closed stderr before printing READY") + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for READY from subprocess") + } + // Drain remaining stderr in the background so the pipe never blocks. + go func() { _, _ = io.Copy(io.Discard, r) }() +} + +// testWriter forwards cobra output to t.Log so it does not leak onto stderr. +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { + w.t.Log(string(p)) + return len(p), nil +} diff --git a/plugins/pass/commands/run_unix.go b/plugins/pass/commands/run_unix.go new file mode 100644 index 00000000..5072fea7 --- /dev/null +++ b/plugins/pass/commands/run_unix.go @@ -0,0 +1,36 @@ +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package commands + +import ( + "os" + "syscall" +) + +func forwardableSignals() []os.Signal { + return []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP} +} + +func childExitCode(state *os.ProcessState) int { + if state.Exited() { + return state.ExitCode() + } + if ws, ok := state.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return 128 + int(ws.Signal()) + } + return state.ExitCode() +} diff --git a/plugins/pass/commands/run_windows.go b/plugins/pass/commands/run_windows.go new file mode 100644 index 00000000..1800550c --- /dev/null +++ b/plugins/pass/commands/run_windows.go @@ -0,0 +1,35 @@ +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package commands + +import ( + "os" + "syscall" +) + +// On Windows only SIGINT can be sent to another process via os.Process.Signal; +// SIGTERM and SIGHUP are accepted by signal.Notify but cannot be forwarded to +// a child process. +func forwardableSignals() []os.Signal { + return []os.Signal{syscall.SIGINT} +} + +// Windows does not surface a signaled-process state through ProcessState; +// ExitCode() is authoritative for both normal and abnormal termination. +func childExitCode(state *os.ProcessState) int { + return state.ExitCode() +} diff --git a/plugins/pass/go.mod b/plugins/pass/go.mod index 05e3b34f..fce98b7b 100644 --- a/plugins/pass/go.mod +++ b/plugins/pass/go.mod @@ -2,11 +2,14 @@ module github.com/docker/secrets-engine/plugins/pass go 1.25.10 +replace github.com/docker/secrets-engine/client => ./../../client + replace github.com/docker/secrets-engine/store => ./../../store replace github.com/docker/secrets-engine/x => ./../../x require ( + github.com/docker/secrets-engine/client v0.0.9 github.com/docker/secrets-engine/plugin v0.0.22 github.com/docker/secrets-engine/store v0.0.23 github.com/docker/secrets-engine/x v0.0.32-do.not.use diff --git a/vendor/modules.txt b/vendor/modules.txt index e717e402..31f0bba2 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -39,6 +39,8 @@ github.com/davecgh/go-spew/spew ## explicit; go 1.21 github.com/docker/docker-credential-helpers/client github.com/docker/docker-credential-helpers/credentials +# github.com/docker/secrets-engine/client v0.0.9 => ./client +## explicit; go 1.25.10 # github.com/docker/secrets-engine/plugin v0.0.22 => ./plugin ## explicit; go 1.25.10 # github.com/docker/secrets-engine/store v0.0.23 => ./store