Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/pass/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions plugins/pass/commands/run.go
Original file line number Diff line number Diff line change
@@ -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()...)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal handler registered after child.Start() — signals in the gap orphan the child

child.Start() is called at line 80, but signal.Notify(sigCh, forwardableSignals()...) is not called until line 85. If SIGINT, SIGTERM, or SIGHUP arrives in that window, Go's default handler terminates the parent immediately. The child process, already running, is reparented to init/PID 1 and continues executing indefinitely — a process and secret leak.

Fix: call signal.Notify before child.Start():

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, forwardableSignals()...)
defer signal.Stop(sigCh)

if err := child.Start(); err != nil {
    return fmt.Errorf("starting child: %w", err)
}

This eliminates the race entirely.

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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.Exit() inside wrapRunEWithSpan bypasses OTel span.End() and metrics

command.go wraps every subcommand with wrapRunEWithSpanwithOTEL, which calls defer span.End() and calledMetric() after runE() returns. But when the child exits non-zero, run.go calls os.Exit(childExitCode(exitErr.ProcessState)) directly — bypassing all deferred functions and never returning to the withOTEL wrapper.

Result: every non-zero child exit (the most operationally common case) produces an unclosed OTel span and a missing metric event. Only the success path (child exits 0, RunE returns nil) is correctly instrumented.

Fix: return a sentinel error (or a dedicated exit-code error type) from RunE, let withOTEL complete normally, then call os.Exit in a PersistentPostRunE or via a top-level error handler that runs outside the span wrapper.

}
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
}
Loading
Loading