Skip to content

Commit c00030a

Browse files
authored
Merge pull request #2083 from dgageot/secu
fix: prevent PATH hijacking and TOCTOU in shell/binary resolution
2 parents 20ba16f + 24d947c commit c00030a

8 files changed

Lines changed: 220 additions & 53 deletions

File tree

pkg/environment/cmd_provider.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
// runCommand executes a command and returns its trimmed stdout.
1313
// Returns ("", false) if the command fails or is not found.
14+
// The name parameter should be a fully-qualified path to the binary
15+
// (as returned by lookupBinary) to prevent PATH hijacking (CWE-426).
1416
func runCommand(ctx context.Context, logLabel, name string, args ...string) (string, bool) {
1517
var stdout, stderr bytes.Buffer
1618

@@ -26,15 +28,17 @@ func runCommand(ctx context.Context, logLabel, name string, args ...string) (str
2628
return strings.TrimSpace(stdout.String()), true
2729
}
2830

29-
// lookupBinary checks if a binary is available on the system PATH.
30-
// Returns a non-nil error if the binary is not found.
31-
func lookupBinary(name string, notFoundErr error) error {
31+
// lookupBinary checks if a binary is available on the system PATH and returns
32+
// its absolute path. Returns a non-nil error if the binary is not found.
33+
// The returned path should be stored and reused (rather than the bare name)
34+
// to avoid TOCTOU races and PATH hijacking.
35+
func lookupBinary(name string, notFoundErr error) (string, error) {
3236
path, err := exec.LookPath(name)
3337
if err != nil && !errors.Is(err, exec.ErrNotFound) {
3438
slog.Warn("failed to lookup `"+name+"` binary", "error", err)
3539
}
3640
if path == "" {
37-
return notFoundErr
41+
return "", notFoundErr
3842
}
39-
return nil
43+
return path, nil
4044
}

pkg/environment/keychain.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import "context"
44

55
// KeychainProvider is a provider that retrieves secrets using the macOS keychain
66
// via the `security` command-line tool.
7-
type KeychainProvider struct{}
7+
type KeychainProvider struct {
8+
// binaryPath is the absolute path to the `security` binary, resolved at
9+
// construction time to avoid TOCTOU races and PATH hijacking (CWE-426).
10+
binaryPath string
11+
}
812

913
type KeychainNotAvailableError struct{}
1014

@@ -13,16 +17,18 @@ func (KeychainNotAvailableError) Error() string {
1317
}
1418

1519
// NewKeychainProvider creates a new KeychainProvider instance.
16-
// It verifies that the `security` command is available on the system.
20+
// It verifies that the `security` command is available on the system and
21+
// stores the resolved absolute path for later use.
1722
func NewKeychainProvider() (*KeychainProvider, error) {
18-
if err := lookupBinary("security", KeychainNotAvailableError{}); err != nil {
23+
path, err := lookupBinary("security", KeychainNotAvailableError{})
24+
if err != nil {
1925
return nil, err
2026
}
21-
return &KeychainProvider{}, nil
27+
return &KeychainProvider{binaryPath: path}, nil
2228
}
2329

2430
// Get retrieves the value of a secret by its service name from the macOS keychain.
2531
// It uses the `security find-generic-password -w -s <name>` command to fetch the password.
2632
func (p *KeychainProvider) Get(ctx context.Context, name string) (string, bool) {
27-
return runCommand(ctx, "keychain", "security", "find-generic-password", "-w", "-s", name)
33+
return runCommand(ctx, "keychain", p.binaryPath, "find-generic-password", "-w", "-s", name)
2834
}

pkg/environment/pass.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import "context"
44

55
// PassProvider is a provider that retrieves secrets using the `pass` password
66
// manager.
7-
type PassProvider struct{}
7+
type PassProvider struct {
8+
// binaryPath is the absolute path to the `pass` binary, resolved at
9+
// construction time to avoid TOCTOU races and PATH hijacking (CWE-426).
10+
binaryPath string
11+
}
812

913
type PassNotAvailableError struct{}
1014

@@ -13,15 +17,17 @@ func (PassNotAvailableError) Error() string {
1317
}
1418

1519
// NewPassProvider creates a new PassProvider instance.
20+
// It verifies that `pass` is available and stores the resolved absolute path.
1621
func NewPassProvider() (*PassProvider, error) {
17-
if err := lookupBinary("pass", PassNotAvailableError{}); err != nil {
22+
path, err := lookupBinary("pass", PassNotAvailableError{})
23+
if err != nil {
1824
return nil, err
1925
}
20-
return &PassProvider{}, nil
26+
return &PassProvider{binaryPath: path}, nil
2127
}
2228

2329
// Get retrieves the value of a secret by its name using the `pass` CLI.
2430
// The name corresponds to the path in the `pass` store.
2531
func (p *PassProvider) Get(ctx context.Context, name string) (string, bool) {
26-
return runCommand(ctx, "pass", "pass", "show", name)
32+
return runCommand(ctx, "pass", p.binaryPath, "show", name)
2733
}

pkg/hooks/executor.go

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@ package hooks
22

33
import (
44
"bytes"
5-
"cmp"
65
"context"
76
"encoding/json"
87
"errors"
98
"fmt"
109
"log/slog"
1110
"maps"
12-
"os"
1311
"os/exec"
1412
"regexp"
15-
"runtime"
1613
"strings"
1714
"sync"
15+
16+
"github.com/docker/docker-agent/pkg/shellpath"
1817
)
1918

2019
// Executor handles the execution of hooks
@@ -64,24 +63,11 @@ func NewExecutor(config *Config, workingDir string, env []string) *Executor {
6463
return e
6564
}
6665

67-
// initShell initializes the shell configuration based on the OS
66+
// initShell initializes the shell configuration based on the OS.
67+
// It uses shellpath.DetectShell which resolves shell binaries via absolute
68+
// paths to prevent PATH hijacking (CWE-426).
6869
func (e *Executor) initShell() {
69-
if runtime.GOOS == "windows" {
70-
// Prefer PowerShell when available
71-
if path, err := exec.LookPath("pwsh.exe"); err == nil {
72-
e.shell = path
73-
e.shellArgsPrefix = []string{"-NoProfile", "-NonInteractive", "-Command"}
74-
} else if path, err := exec.LookPath("powershell.exe"); err == nil {
75-
e.shell = path
76-
e.shellArgsPrefix = []string{"-NoProfile", "-NonInteractive", "-Command"}
77-
} else {
78-
e.shell = cmp.Or(os.Getenv("ComSpec"), "cmd.exe")
79-
e.shellArgsPrefix = []string{"/C"}
80-
}
81-
} else {
82-
e.shell = cmp.Or(os.Getenv("SHELL"), "/bin/sh")
83-
e.shellArgsPrefix = []string{"-c"}
84-
}
70+
e.shell, e.shellArgsPrefix = shellpath.DetectShell()
8571
}
8672

8773
// compileMatchers pre-compiles all matcher regex patterns

pkg/shellpath/shellpath.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Package shellpath provides safe shell binary resolution to prevent
2+
// PATH hijacking attacks (CWE-426).
3+
package shellpath
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
)
11+
12+
// WindowsCmdExe returns the absolute path to cmd.exe on Windows using the
13+
// SystemRoot environment variable (e.g. C:\Windows\System32\cmd.exe).
14+
// This avoids resolving cmd.exe through PATH, which would be vulnerable
15+
// to untrusted search path attacks (CWE-426).
16+
//
17+
// If the ComSpec environment variable is set, its value is returned as-is
18+
// (ComSpec is typically set by Windows itself to the correct cmd.exe path).
19+
//
20+
// As a last resort, if neither ComSpec nor SystemRoot is set, it falls back
21+
// to the bare "cmd.exe" name (should never happen on a normal Windows system).
22+
func WindowsCmdExe() string {
23+
if comspec := os.Getenv("ComSpec"); comspec != "" {
24+
return comspec
25+
}
26+
if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" {
27+
return filepath.Join(systemRoot, "System32", "cmd.exe")
28+
}
29+
return "cmd.exe"
30+
}
31+
32+
// DetectShell returns the appropriate shell binary and its argument prefix
33+
// for the current platform.
34+
//
35+
// On Windows, it prefers PowerShell (pwsh.exe or powershell.exe) resolved
36+
// via exec.LookPath (which returns an absolute path on success), falling
37+
// back to cmd.exe resolved through [WindowsCmdExe].
38+
//
39+
// On Unix, it uses the SHELL environment variable or /bin/sh.
40+
func DetectShell() (shell string, argsPrefix []string) {
41+
if runtime.GOOS == "windows" {
42+
return DetectWindowsShell()
43+
}
44+
45+
return defaultUnixShell(), []string{"-c"}
46+
}
47+
48+
// DetectWindowsShell returns the shell binary and argument prefix for Windows.
49+
// It prefers PowerShell (resolved via LookPath, which returns an absolute path),
50+
// falling back to cmd.exe via [WindowsCmdExe].
51+
func DetectWindowsShell() (shell string, argsPrefix []string) {
52+
powershellArgs := []string{"-NoProfile", "-NonInteractive", "-Command"}
53+
for _, ps := range []string{"pwsh.exe", "powershell.exe"} {
54+
if path, err := exec.LookPath(ps); err == nil {
55+
return path, powershellArgs
56+
}
57+
}
58+
return WindowsCmdExe(), []string{"/C"}
59+
}
60+
61+
// DetectUnixShell returns the user's shell from the SHELL environment variable,
62+
// falling back to /bin/sh.
63+
func DetectUnixShell() string {
64+
return defaultUnixShell()
65+
}
66+
67+
// defaultUnixShell returns the user's shell from SHELL or /bin/sh.
68+
func defaultUnixShell() string {
69+
if shell := os.Getenv("SHELL"); shell != "" {
70+
return shell
71+
}
72+
return "/bin/sh"
73+
}

pkg/shellpath/shellpath_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package shellpath
2+
3+
import (
4+
"path/filepath"
5+
"runtime"
6+
"testing"
7+
)
8+
9+
func TestWindowsCmdExe_ComSpec(t *testing.T) {
10+
t.Setenv("ComSpec", `C:\Custom\cmd.exe`)
11+
got := WindowsCmdExe()
12+
if got != `C:\Custom\cmd.exe` {
13+
t.Errorf("WindowsCmdExe() = %q, want %q", got, `C:\Custom\cmd.exe`)
14+
}
15+
}
16+
17+
func TestWindowsCmdExe_SystemRoot(t *testing.T) {
18+
t.Setenv("ComSpec", "")
19+
t.Setenv("SystemRoot", `C:\Windows`)
20+
got := WindowsCmdExe()
21+
want := `C:\Windows` + string(filepath.Separator) + filepath.Join("System32", "cmd.exe")
22+
if got != want {
23+
t.Errorf("WindowsCmdExe() = %q, want %q", got, want)
24+
}
25+
}
26+
27+
func TestWindowsCmdExe_Fallback(t *testing.T) {
28+
t.Setenv("ComSpec", "")
29+
t.Setenv("SystemRoot", "")
30+
got := WindowsCmdExe()
31+
if got != "cmd.exe" {
32+
t.Errorf("WindowsCmdExe() = %q, want %q", got, "cmd.exe")
33+
}
34+
}
35+
36+
func TestDetectShell_Unix(t *testing.T) {
37+
if runtime.GOOS == "windows" {
38+
t.Skip("Unix-only test")
39+
}
40+
41+
t.Setenv("SHELL", "/bin/zsh")
42+
shell, args := DetectShell()
43+
if shell != "/bin/zsh" {
44+
t.Errorf("DetectShell() shell = %q, want /bin/zsh", shell)
45+
}
46+
if len(args) != 1 || args[0] != "-c" {
47+
t.Errorf("DetectShell() args = %v, want [-c]", args)
48+
}
49+
}
50+
51+
func TestDetectShell_Unix_Fallback(t *testing.T) {
52+
if runtime.GOOS == "windows" {
53+
t.Skip("Unix-only test")
54+
}
55+
56+
t.Setenv("SHELL", "")
57+
shell, _ := DetectShell()
58+
if shell != "/bin/sh" {
59+
t.Errorf("DetectShell() shell = %q, want /bin/sh", shell)
60+
}
61+
}
62+
63+
func TestDefaultUnixShell(t *testing.T) {
64+
t.Setenv("SHELL", "/usr/local/bin/fish")
65+
got := defaultUnixShell()
66+
if got != "/usr/local/bin/fish" {
67+
t.Errorf("defaultUnixShell() = %q, want /usr/local/bin/fish", got)
68+
}
69+
70+
t.Setenv("SHELL", "")
71+
got = defaultUnixShell()
72+
if got != "/bin/sh" {
73+
t.Errorf("defaultUnixShell() = %q, want /bin/sh", got)
74+
}
75+
}
76+
77+
func TestWindowsCmdExe_PrefersComSpecOverSystemRoot(t *testing.T) {
78+
// When both are set, ComSpec should take priority
79+
t.Setenv("ComSpec", `D:\Tools\cmd.exe`)
80+
t.Setenv("SystemRoot", `C:\Windows`)
81+
got := WindowsCmdExe()
82+
if got != `D:\Tools\cmd.exe` {
83+
t.Errorf("WindowsCmdExe() = %q, want %q (ComSpec should take priority)", got, `D:\Tools\cmd.exe`)
84+
}
85+
}
86+
87+
func TestDetectWindowsShell_FallbackUsesAbsolutePath(t *testing.T) {
88+
// Simulate an environment where no PowerShell is found:
89+
// set PATH to empty so LookPath won't find pwsh.exe or powershell.exe
90+
t.Setenv("PATH", "")
91+
92+
t.Setenv("ComSpec", "")
93+
t.Setenv("SystemRoot", `C:\Windows`)
94+
95+
shell, args := DetectWindowsShell()
96+
want := `C:\Windows` + string(filepath.Separator) + filepath.Join("System32", "cmd.exe")
97+
if shell != want {
98+
t.Errorf("DetectWindowsShell() shell = %q, want %q", shell, want)
99+
}
100+
if len(args) != 1 || args[0] != "/C" {
101+
t.Errorf("DetectWindowsShell() args = %v, want [/C]", args)
102+
}
103+
}

pkg/tools/builtin/shell.go

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import (
1010
"os"
1111
"os/exec"
1212
"path/filepath"
13-
"runtime"
1413
"strings"
1514
"sync"
1615
"sync/atomic"
1716
"time"
1817

1918
"github.com/docker/docker-agent/pkg/concurrent"
2019
"github.com/docker/docker-agent/pkg/config"
20+
"github.com/docker/docker-agent/pkg/shellpath"
2121
"github.com/docker/docker-agent/pkg/tools"
2222
)
2323

@@ -356,22 +356,10 @@ func NewShellTool(env []string, runConfig *config.RuntimeConfig) *ShellTool {
356356
}
357357

358358
// detectShell returns the appropriate shell and arguments based on the platform.
359+
// It delegates to shellpath.DetectShell which uses absolute paths to prevent
360+
// PATH hijacking (CWE-426).
359361
func detectShell() (shell string, argsPrefix []string) {
360-
if runtime.GOOS == "windows" {
361-
return detectWindowsShell()
362-
}
363-
364-
return cmp.Or(os.Getenv("SHELL"), "/bin/sh"), []string{"-c"}
365-
}
366-
367-
func detectWindowsShell() (shell string, argsPrefix []string) {
368-
powershellArgs := []string{"-NoProfile", "-NonInteractive", "-Command"}
369-
for _, ps := range []string{"pwsh.exe", "powershell.exe"} {
370-
if path, err := exec.LookPath(ps); err == nil {
371-
return path, powershellArgs
372-
}
373-
}
374-
return cmp.Or(os.Getenv("ComSpec"), "cmd.exe"), []string{"/C"}
362+
return shellpath.DetectShell()
375363
}
376364

377365
// resolveWorkDir returns the effective working directory.

0 commit comments

Comments
 (0)