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
9 changes: 2 additions & 7 deletions plugins/pass/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,6 @@ func parseEnv(env []string) ([]envVar, error) {
key, value, _ := strings.Cut(kv, "=")
v := envVar{key: key, value: value}
if rawSeRef, ok := strings.CutPrefix(value, sePrefix); ok {
// ParseID rejects wildcards before ParsePattern broadens the lookup.
if _, err := secrets.ParseID(rawSeRef); err != nil {
return nil, fmt.Errorf("resolving %s: %w", key, err)
}
pattern, err := secrets.ParsePattern(rawSeRef)
if err != nil {
return nil, fmt.Errorf("resolving %s: %w", key, err)
Expand Down Expand Up @@ -317,8 +313,7 @@ func resolveVar(ctx context.Context, r secrets.Resolver, v envVar) (string, erro
if len(envs) == 0 {
return "", fmt.Errorf("resolving %s: %w", v.key, secrets.ErrNotFound)
}
if len(envs) > 1 {
return "", fmt.Errorf("resolving %s: %d secrets matched %s", v.key, len(envs), v.pattern)
}
// TODO: Extend the query language
// For now: First match wins.
Comment on lines +316 to +317

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[high / security] Non-deterministic secret selection when wildcard matches multiple secrets

The PR removes the len(envs) > 1 error guard and replaces it with "first match wins" — but neither the secrets.Resolver interface nor the production resolverClient (in x/api/resolver/resolver.go) defines or enforces any ordering on the returned slice. The concrete RPC client returns envelopes in the order the daemon's wire response provides them, which can vary across runs, daemon versions, or backend ordering changes.

Trigger: any se:// env var containing * or ** where the pattern matches two or more secrets.

Impact:

  1. The secret injected into the child process's environment is effectively non-deterministic in production.
  2. A user relying on a wildcard that currently resolves to a single secret will silently receive a different credential if a second matching secret is added later — no error is surfaced.

Why tests pass but production is unsafe: MockResolver.GetSecrets in x/testhelper/testing.go sorts results alphabetically by ID before returning (line 105 via slices.SortFunc). This makes the wildcard test deterministic but masks the production non-determinism, where no such sort is applied.

Fix: sort the returned envelopes by ID before selecting index 0, and optionally warn when len(envs) > 1 so the caller is aware of the disambiguation:

Suggested change
// TODO: Extend the query language
// For now: First match wins.
slices.SortFunc(envs, func(a, b secrets.Envelope) int { return strings.Compare(a.ID.String(), b.ID.String()) })
// First match wins (lowest lexicographic ID). Warn if ambiguous.
Confidence Score
🟢 strong 100/100

return string(envs[0].Value), nil
}
6 changes: 6 additions & 0 deletions plugins/pass/commands/run_example.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ $ SE_TOKEN=se://gh-token docker pass run -- gh repo list
$ DB_PASSWORD=se://myapp/postgres/password API_KEY=se://myapp/anthropic/api-key docker pass run -- ./my-binary
```

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

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

### Resolve references from a dotenv file:

```console
Expand Down
1 change: 1 addition & 0 deletions plugins/pass/commands/run_long.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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.
The child inherits stdin, stdout, and stderr.

Requires the secrets-engine daemon (Docker Desktop) to be running.
Expand Down
26 changes: 18 additions & 8 deletions plugins/pass/commands/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,18 @@ func TestParseEnv(t *testing.T) {
assert.Equal(t, []envVar{{key: "DSN", value: "postgres://user:se://gh-token@host/db"}}, vars)
})

t.Run("invalid ID hard-fails", func(t *testing.T) {
vars, err := parseEnv([]string{"X=se://"})
require.Error(t, err)
assert.Nil(t, vars)
assert.ErrorContains(t, err, "resolving X")
t.Run("wildcard reference carries its pattern", func(t *testing.T) {
vars, err := parseEnv([]string{"X=se://foo/*", "Y=se://**/token"})
require.NoError(t, err)
assert.Equal(t, []envVar{
{key: "X", value: "se://foo/*", pattern: secrets.MustParsePattern("foo/*")},
{key: "Y", value: "se://**/token", pattern: secrets.MustParsePattern("**/token")},
}, vars)
})

t.Run("wildcard in reference is rejected", func(t *testing.T) {
vars, err := parseEnv([]string{"X=se://foo/*"})
require.Error(t, err)
t.Run("invalid pattern hard-fails", func(t *testing.T) {
vars, err := parseEnv([]string{"X=se://"})
require.ErrorIs(t, err, secrets.ErrInvalidPattern)
assert.Nil(t, vars)
assert.ErrorContains(t, err, "resolving X")
})
Expand All @@ -190,6 +192,7 @@ func TestResolveEnv(t *testing.T) {
Store: map[secrets.ID]string{
secrets.MustParseID("gh-token"): "ghp_abc123",
secrets.MustParseID("myapp/postgres/password"): "s3cr3t",
secrets.MustParseID("other/postgres/password"): "0th3r",
},
}

Expand All @@ -214,6 +217,13 @@ func TestResolveEnv(t *testing.T) {
assert.Equal(t, []string{"PG_PWD=s3cr3t"}, out)
})

t.Run("wildcard resolves to the first match", func(t *testing.T) {
in := []string{"PG_PWD=se://*/postgres/password"}
out, err := resolveEnv(t.Context(), mock, mustParseEnv(t, in))
require.NoError(t, err)
assert.Equal(t, []string{"PG_PWD=s3cr3t"}, out)
})

t.Run("missing reference hard-fails", func(t *testing.T) {
in := []string{"X=se://does-not-exist"}
out, err := resolveEnv(t.Context(), mock, mustParseEnv(t, in))
Expand Down
Loading