Skip to content
Merged
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
14 changes: 12 additions & 2 deletions plugins/pass/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ var runLong string
type runOpts struct {
clientOpts
envFiles []string
env []string
}

// RunCommand uses [api.DesktopSocketPath] by default.
Expand All @@ -72,7 +73,7 @@ func newRunCommand(opts runOpts) *cobra.Command {
Example: strings.Trim(runExample, "\n"),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
merged, err := mergeEnv(os.Environ(), opts.envFiles)
merged, err := mergeEnv(os.Environ(), opts.envFiles, opts.env)
if err != nil {
return err
}
Expand Down Expand Up @@ -147,10 +148,12 @@ func newRunCommand(opts runOpts) *cobra.Command {
}
cmd.Flags().StringArrayVar(&opts.envFiles, "env-file", nil,
"Read environment variables from a dotenv-formatted file. Repeatable; later files override earlier files and the process environment.")
cmd.Flags().StringArrayVarP(&opts.env, "env", "e", nil,
"Set an environment variable as `KEY=VALUE`; the value may be an se:// reference. Repeatable; overrides --env-file and the process environment.")
return cmd
}

func mergeEnv(processEnv, files []string) ([]string, error) {
func mergeEnv(processEnv, files, env []string) ([]string, error) {
merged := make(map[string]string, len(processEnv))
for _, kv := range processEnv {
if k, v, ok := strings.Cut(kv, "="); ok {
Expand All @@ -164,6 +167,13 @@ func mergeEnv(processEnv, files []string) ([]string, error) {
}
maps.Copy(merged, parsed)
}
for _, kv := range env {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", kv)
}
merged[k] = v
}
out := make([]string, 0, len(merged))
for k, v := range merged {
out = append(out, k+"="+v)
Expand Down
28 changes: 20 additions & 8 deletions plugins/pass/commands/run_example.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
### Run a command with one secret in its environment:

```console
$ SE_TOKEN=se://gh-token docker pass run -- gh repo list
$ docker pass run -e GH_TOKEN=se://gh-token -- gh repo list
```

### Multiple references:
### Pass several secrets to a shell script, which reads them like any other variable:

```console
$ DB_PASSWORD=se://myapp/postgres/password API_KEY=se://myapp/anthropic/api-key docker pass run -- ./my-binary
$ docker pass run -e DB_PASSWORD=se://myapp/password -e API_KEY=se://myapp/api-key -- ./deploy.sh
```

### Resolve references from a dotenv file:

```console
$ docker pass run --env-file .env -- ./deploy.sh
```

### Inline shell (single quotes leave `$DB_PASSWORD` for the child shell to expand):

```console
$ docker pass run -e DB_PASSWORD=se://myapp/password -- sh -c 'pg_dump "postgres://app:$DB_PASSWORD@db/app" > backup.sql'
```

### Wildcard pattern (the first matching secret is used):

```console
$ DB_PASSWORD=se://*/postgres/password docker pass run -- ./my-binary
$ docker pass run -e DB_PASSWORD=se://*/password -- ./deploy.sh
```

### Resolve references from a dotenv file:
### References already in the calling environment are resolved too:

```console
$ docker pass run --env-file .env -- ./my-binary
$ GH_TOKEN=se://gh-token docker pass run -- gh repo list
```

### Multiple files (later overrides earlier; files override the process environment):
### Combine sources (`--env` overrides `--env-file`; later files override earlier ones; all override the process environment):

```console
$ docker pass run --env-file .env --env-file .env.local -- ./my-binary
$ docker pass run --env-file .env --env-file .env.local -e DB_PASSWORD=se://myapp/postgres/password -- ./deploy.sh
```
9 changes: 5 additions & 4 deletions plugins/pass/commands/run_long.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
Scans the current environment (plus any `--env-file` inputs) for variables
whose value is exactly `se://<ID|pattern>`. Each reference is resolved through the
secrets-engine daemon and the resolved value is passed to the child process.
When several secrets match, the first match is used.
Scans the current environment (plus any `--env-file` and `--env` inputs) for
variables whose value is exactly `se://<ID|pattern>`. Each reference is resolved
through the secrets-engine daemon and the resolved value is passed to the child
process. When several secrets match, the first match is used.
`--env` overrides `--env-file`; both override the process environment.
The child inherits stdin, stdout, and stderr.

Requires the secrets-engine daemon (Docker Desktop) to be running.
Expand Down
77 changes: 70 additions & 7 deletions plugins/pass/commands/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,29 +249,29 @@ func TestMergeEnv(t *testing.T) {
t.Parallel()

t.Run("no files returns sorted process env", func(t *testing.T) {
out, err := mergeEnv([]string{"B=2", "A=1"}, nil)
out, err := mergeEnv([]string{"B=2", "A=1"}, nil, nil)
require.NoError(t, err)
assert.Equal(t, []string{"A=1", "B=2"}, out)
})

t.Run("file overrides process env", func(t *testing.T) {
f := writeEnvFile(t, "A=from-file\nC=new\n")
out, err := mergeEnv([]string{"A=from-process", "B=keep"}, []string{f})
out, err := mergeEnv([]string{"A=from-process", "B=keep"}, []string{f}, nil)
require.NoError(t, err)
assert.Equal(t, []string{"A=from-file", "B=keep", "C=new"}, out)
})

t.Run("later file overrides earlier file", func(t *testing.T) {
f1 := writeEnvFile(t, "A=from-file-1\n")
f2 := writeEnvFile(t, "A=from-file-2\n")
out, err := mergeEnv(nil, []string{f1, f2})
out, err := mergeEnv(nil, []string{f1, f2}, nil)
require.NoError(t, err)
assert.Equal(t, []string{"A=from-file-2"}, out)
})

t.Run("comments and quoted values", func(t *testing.T) {
f := writeEnvFile(t, "# this is a comment\nGREETING=\"hello world\"\nQUOTED='no $expand'\n")
out, err := mergeEnv(nil, []string{f})
out, err := mergeEnv(nil, []string{f}, nil)
require.NoError(t, err)
assert.Equal(t, []string{
"GREETING=hello world",
Expand All @@ -281,17 +281,45 @@ func TestMergeEnv(t *testing.T) {

t.Run("missing file returns error and does not partially apply", func(t *testing.T) {
f := writeEnvFile(t, "A=present\n")
out, err := mergeEnv([]string{"B=keep"}, []string{f, "/does/not/exist/.env"})
out, err := mergeEnv([]string{"B=keep"}, []string{f, "/does/not/exist/.env"}, nil)
require.Error(t, err)
assert.Nil(t, out)
assert.Contains(t, err.Error(), "/does/not/exist/.env")
})

t.Run("--env overrides files and the process env", func(t *testing.T) {
f := writeEnvFile(t, "A=from-file\nB=from-file\n")
out, err := mergeEnv([]string{"A=from-process", "C=keep"}, []string{f}, []string{"A=from-flag", "D=new"})
require.NoError(t, err)
assert.Equal(t, []string{"A=from-flag", "B=from-file", "C=keep", "D=new"}, out)
})

t.Run("later --env overrides earlier --env", func(t *testing.T) {
out, err := mergeEnv(nil, nil, []string{"A=1", "A=2"})
require.NoError(t, err)
assert.Equal(t, []string{"A=2"}, out)
})

t.Run("--env splits at the first = and allows an empty value", func(t *testing.T) {
out, err := mergeEnv(nil, nil, []string{"CHECK=KEY=WANT", "EMPTY="})
require.NoError(t, err)
assert.Equal(t, []string{"CHECK=KEY=WANT", "EMPTY="}, out)
})

t.Run("--env without KEY=VALUE is rejected", func(t *testing.T) {
for _, bad := range []string{"NOVALUE", "=value", ""} {
out, err := mergeEnv([]string{"B=keep"}, nil, []string{bad})
require.Error(t, err, bad)
assert.Nil(t, out)
assert.ErrorContains(t, err, "expected KEY=VALUE")
}
})

t.Run("preserves se:// values for downstream parseEnv", func(t *testing.T) {
f := writeEnvFile(t, "SE_TOKEN=se://gh-token\nPLAIN=v\n")
out, err := mergeEnv(nil, []string{f})
out, err := mergeEnv(nil, []string{f}, []string{"PG_PWD=se://*/postgres/password"})
require.NoError(t, err)
assert.Equal(t, []string{"PLAIN=v", "SE_TOKEN=se://gh-token"}, out)
assert.Equal(t, []string{"PG_PWD=se://*/postgres/password", "PLAIN=v", "SE_TOKEN=se://gh-token"}, out)
})
}

Expand Down Expand Up @@ -434,6 +462,41 @@ func TestRunCommand(t *testing.T) {
assert.Equal(t, []string{"authorize gh-token", "resolve gh-token"}, engine.recorded())
})

t.Run("resolves references set with --env", func(t *testing.T) {
engine := &mockEngine{allow: true, store: map[secrets.ID]string{
secrets.MustParseID("gh-token"): "ghp_abc123",
}}
// The env-file plants a dangling reference; --env must win over it.
envFile := writeEnvFile(t, "SE_TOKEN=se://does-not-exist\n")
cmd, err := RunCommand(WithTimeout(time.Second), WithSocketPath(engine.serve(t)))
require.NoError(t, err)
cmd.SetArgs([]string{
"--env-file", envFile,
"-e", "SE_TOKEN=se://gh-token",
"-e", helperActiveEnv + "=1",
"--env", helperCheckEnv + "=SE_TOKEN=ghp_abc123",
exe,
})
cmd.SetContext(t.Context())
cmd.SetOut(testWriter{t})
cmd.SetErr(testWriter{t})
require.NoError(t, cmd.Execute())
assert.Equal(t, []string{"authorize gh-token", "resolve gh-token"}, engine.recorded())
})

t.Run("a malformed --env fails before contacting the engine", func(t *testing.T) {
engine := &mockEngine{allow: true}
cmd, err := RunCommand(WithTimeout(time.Second), WithSocketPath(engine.serve(t)))
require.NoError(t, err)
cmd.SetArgs([]string{"-e", "NOVALUE", exe})
cmd.SetContext(t.Context())
cmd.SetOut(testWriter{t})
cmd.SetErr(testWriter{t})
err = cmd.Execute()
require.ErrorContains(t, err, `invalid --env "NOVALUE": expected KEY=VALUE`)
assert.Empty(t, engine.recorded())
})

t.Run("a denied authorization stops before resolving", func(t *testing.T) {
engine := &mockEngine{store: map[secrets.ID]string{
secrets.MustParseID("gh-token"): "ghp_abc123",
Expand Down
Loading