From 38e0dd9fc5ee5b29ac0f4580ccf4c7bd0b9cf3b2 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 13 Aug 2026 13:10:04 +1000 Subject: [PATCH 1/2] Fix helper PATH hijacking Resolve Hermit's internal helper binaries from a trusted system path so repository-controlled environment shims cannot bypass Git transport protections or intercept archive operations. Add unit and integration coverage for the poisoned PATH scenario. Co-authored-by: Codex --- archive/archive.go | 8 +-- cache/git.go | 12 ++-- env.go | 10 ++-- integration/integration_test.go | 40 +++++++++++++ manifest/autoversion/git_tags.go | 7 ++- util/run.go | 96 +++++++++++++++++++++++++++++++- util/run_test.go | 34 +++++++++++ 7 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 util/run_test.go diff --git a/archive/archive.go b/archive/archive.go index 6b9def10..ccbe43c2 100644 --- a/archive/archive.go +++ b/archive/archive.go @@ -209,7 +209,7 @@ func installMacDMG(b *ui.Task, source string, pkg *manifest.Package) error { if err != nil { return errors.WithStack(err) } - output, err := util.Capture(b, "hdiutil", "attach", "-plist", source) + output, err := util.CaptureSystem(b, "hdiutil", "attach", "-plist", source) if err != nil { return errors.Wrap(err, "could not mount DMG") } @@ -228,14 +228,14 @@ func installMacDMG(b *ui.Task, source string, pkg *manifest.Package) error { if entry == nil { return errors.New("couldn't determine volume information from hdiutil attach, volume may still be mounted :(") } - defer util.Run(b, "hdiutil", "detach", entry.DevEntry) //nolint: errcheck + defer util.RunSystem(b, "hdiutil", "detach", entry.DevEntry) //nolint: errcheck switch { case len(pkg.Apps) != 0: for _, app := range pkg.Apps { base := filepath.Base(app) // Use rsync because reliably syncing all filesystem attributes is non-trivial. appDest := filepath.Join(dest, base) - err = util.Run(b, "rsync", "-av", + err = util.RunSystem(b, "rsync", "-av", filepath.Join(entry.MountPoint, app)+"/", appDest+"/") if err != nil { @@ -377,7 +377,7 @@ func extractMacPKG(b *ui.Task, path, dest string, strip int) error { fmt.Fprint(changesf, os.Expand(extractMacPkgChangesXML, func(s string) string { return dest })) _ = changesf.Close() task.Add(1) - return util.Run(b, "installer", "-verbose", + return util.RunSystem(b, "installer", "-verbose", "-pkg", path, "-target", "CurrentUserHomeDirectory", "-applyChoiceChangesXML", changesf.Name()) diff --git a/cache/git.go b/cache/git.go index b025d3bf..9344cf7c 100644 --- a/cache/git.go +++ b/cache/git.go @@ -2,7 +2,6 @@ package cache import ( "os" - "os/exec" "path/filepath" "strings" @@ -32,12 +31,12 @@ func (s *gitSource) Download(b *ui.Task, cache *Cache, checksum string) (string, args = append(args, "--branch="+tag) } args = append(args, "--", repo, checkoutDir) - err = util.RunInDir(b, cache.root, args...) + err = util.RunSystemInDir(b, cache.root, args...) if err != nil { return "", "", "", errors.WithStack(err) } - bts, err := util.CaptureInDir(b, checkoutDir, "git", "rev-parse", "HEAD") + bts, err := util.CaptureSystemInDir(b, checkoutDir, "git", "rev-parse", "HEAD") if err != nil { return "", "", "", errors.WithStack(err) } @@ -54,7 +53,7 @@ func (s *gitSource) ETag(b *ui.Task) (etag string, err error) { if tag == "" { tag = "HEAD" } - bts, err := util.Capture(b, util.GitArgs("ls-remote", "--", repo, tag)...) + bts, err := util.CaptureSystem(b, util.GitArgs("ls-remote", "--", repo, tag)...) if err != nil { return "", errors.Wrap(err, s.URL) } @@ -76,7 +75,10 @@ func (s *gitSource) Validate() error { tag = "HEAD" } args := util.GitArgs("ls-remote", "--", repo, tag) - cmd := exec.Command(args[0], args[1:]...) //nolint + cmd, err := util.SystemCommand(args...) + if err != nil { + return errors.WithStack(err) + } out, err := cmd.CombinedOutput() if err != nil { return errors.Wrapf(err, "error getting remote HEAD: %s", string(out)) diff --git a/env.go b/env.go index 01fd48db..10ee8d67 100644 --- a/env.go +++ b/env.go @@ -173,7 +173,7 @@ func Init(l *ui.UI, env string, distURL string, stateDir string, config Config, } if useGit { - if err = util.RunInDir(b, env, "git", "add", "-f", extDepPath); err != nil { + if err = util.RunSystemInDir(b, env, "git", "add", "-f", extDepPath); err != nil { return errors.WithStack(err) } } @@ -196,7 +196,7 @@ func Init(l *ui.UI, env string, distURL string, stateDir string, config Config, return errors.WithStack(err) } if useGit { - if err = util.RunInDir(b, env, "git", "add", "-f", filepath.Join(bin, "hermit.hcl")); err != nil { + if err = util.RunSystemInDir(b, env, "git", "add", "-f", filepath.Join(bin, "hermit.hcl")); err != nil { return errors.WithStack(err) } } @@ -587,7 +587,7 @@ func (e *Env) unlinkPackage(l *ui.Task, pkg *manifest.Package) error { func (e *Env) unlink(l *ui.Task, path string) error { if e.useGit { - err := util.RunInDir(l, e.envDir, "git", "rm", "-f", path) + err := util.RunSystemInDir(l, e.envDir, "git", "rm", "-f", path) if err != nil { l.Errorf("non-fatal: %s", err) } @@ -1423,7 +1423,7 @@ func (e *Env) linkIntoEnv(l *ui.Task, oldname, newname string) error { return errors.WithStack(err) } if e.useGit { - return util.RunInDir(l, e.envDir, "git", "add", "-f", newname) + return util.RunSystemInDir(l, e.envDir, "git", "add", "-f", newname) } return nil } @@ -1657,7 +1657,7 @@ func writeFileToEnvBin(l *ui.Task, useGit bool, src, envDir string, vars map[str return errors.WithStack(err) } if useGit { - if err = util.RunInDir(l, envDir, "git", "add", "-f", dest); err != nil { + if err = util.RunSystemInDir(l, envDir, "git", "add", "-f", dest); err != nil { return errors.WithStack(err) } } diff --git a/integration/integration_test.go b/integration/integration_test.go index d232fb6d..91e1883c 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -171,6 +171,46 @@ EOF `, expectations: exp{outputContains("remote helpers are not supported")}, }, + { + // Regression test for DX-29: Hermit's own helpers must not resolve from + // the environment bin directory that Hermit prepends to PATH. + name: "SystemHelpersIgnoreHermitBin", + script: ` + SYSTEM_GIT=$(command -v git) + mkdir source.git + "$SYSTEM_GIT" init -q source.git + cat > source.git/safehelper.hcl <<'EOF' +description = "Package from a safely cloned source" +source = "https://example.com/safehelper-${version}" +version "1.0.0" {} +EOF + "$SYSTEM_GIT" -C source.git add safehelper.hcl + "$SYSTEM_GIT" -C source.git \ + -c user.name=Hermit \ + -c user.email=hermit@example.com \ + commit -qm initial + + hermit init --no-git . + . bin/activate-hermit + cat > bin/hermit.hcl < bin/git <<'EOF' +#!/bin/sh +touch "$(dirname "$0")/../RCE.txt" +exit 1 +EOF + chmod +x bin/git + hash -r 2>/dev/null || true + rehash 2>/dev/null || true + assert test "$(command -v git)" = "$PWD/bin/git" + + hermit search safehelper + assert test ! -e RCE.txt + `, + expectations: exp{outputContains("safehelper")}, + }, { name: "InitBasicDefaultsToTrue", script: ` diff --git a/manifest/autoversion/git_tags.go b/manifest/autoversion/git_tags.go index 6206fe9e..5f28f627 100644 --- a/manifest/autoversion/git_tags.go +++ b/manifest/autoversion/git_tags.go @@ -2,7 +2,6 @@ package autoversion import ( "bufio" - "os/exec" "regexp" "sort" "strings" @@ -32,7 +31,11 @@ func gitTagsAutoVersion(autoVersion *manifest.AutoVersionBlock) (string, error) // TAB LF // source: https://git-scm.com/docs/git-ls-remote args := util.GitArgs("ls-remote", "--tags", "--refs", "--", remoteURL) - out, err := exec.Command(args[0], args[1:]...).Output() //nolint:noctx,gosec + cmd, err := util.SystemCommand(args...) + if err != nil { + return "", errors.WithStack(err) + } + out, err := cmd.Output() if err != nil { return "", errors.Wrapf(err, "error listing tags for %s", remoteURL) } diff --git a/util/run.go b/util/run.go index b26af764..8a7cedbf 100644 --- a/util/run.go +++ b/util/run.go @@ -3,7 +3,9 @@ package util import ( "bytes" "io" + "os" "os/exec" + "path/filepath" "strings" "github.com/kballard/go-shellquote" @@ -22,7 +24,55 @@ type CommandRunner interface { type RealCommandRunner struct{} func (g *RealCommandRunner) RunInDir(task *ui.Task, dir string, commands ...string) error { - return errors.WithStack(RunInDir(task, dir, commands...)) + return errors.WithStack(RunSystemInDir(task, dir, commands...)) +} + +// systemPath is the trusted path used for Hermit's own helper processes. In +// particular, it excludes Hermit environment bin directories, which may be +// controlled by the repository being operated on. +func systemPath() string { + return "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +} + +// SystemCommand constructs a command for one of Hermit's own system helpers. +// The executable is resolved only from the system path, and the same path is +// inherited by the helper's child processes. +func SystemCommand(args ...string) (*exec.Cmd, error) { + if len(args) == 0 { + return nil, errors.New("missing system command") + } + name := args[0] + if filepath.Base(name) != name { + return nil, errors.Errorf("system command must be a bare name: %q", name) + } + var path string + for _, dir := range filepath.SplitList(systemPath()) { + candidate := filepath.Join(dir, name) + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() && info.Mode().Perm()&0111 != 0 { + path = candidate + break + } + } + if path == "" { + return nil, &exec.Error{Name: name, Err: exec.ErrNotFound} + } + cmd := exec.Command(path, args[1:]...) //nolint:noctx + cmd.Env = systemEnviron() + return cmd, nil +} + +func systemEnviron() []string { + environ := os.Environ() + out := make([]string, 0, len(environ)+1) + for _, entry := range environ { + key, _, _ := strings.Cut(entry, "=") + if strings.EqualFold(key, "PATH") { + continue + } + out = append(out, entry) + } + return append(out, "PATH="+systemPath()) } // Run a command, outputting to stdout and stderr. @@ -30,6 +80,11 @@ func Run(log *ui.Task, args ...string) error { return RunInDir(log, "", args...) } +// RunSystem runs one of Hermit's own system helpers. +func RunSystem(log *ui.Task, args ...string) error { + return RunSystemInDir(log, "", args...) +} + // Capture runs a command, returning combined stdout and stderr. func Capture(log ui.Logger, args ...string) ([]byte, error) { log.Debugf("%s", shellquote.Join(args...)) @@ -37,6 +92,23 @@ func Capture(log ui.Logger, args ...string) ([]byte, error) { return captureOutput(log, cmd) } +// CaptureSystem runs one of Hermit's own system helpers and returns its output. +func CaptureSystem(log ui.Logger, args ...string) ([]byte, error) { + return CaptureSystemInDir(log, "", args...) +} + +// CaptureSystemInDir runs one of Hermit's own system helpers in the given dir +// and returns its output. +func CaptureSystemInDir(log ui.Logger, dir string, args ...string) ([]byte, error) { + log.Debugf("%s", shellquote.Join(args...)) + cmd, err := SystemCommand(args...) + if err != nil { + return nil, errors.WithStack(err) + } + cmd.Dir = dir + return captureOutput(log, cmd) +} + // CaptureInDir runs a command in the given dir, returning combined stdout and stderr. func CaptureInDir(log ui.Logger, dir string, args ...string) ([]byte, error) { log.Debugf("%s", shellquote.Join(args...)) @@ -69,6 +141,28 @@ func RunInDir(log *ui.Task, dir string, args ...string) error { return nil } +// RunSystemInDir runs one of Hermit's own system helpers in the given dir. +func RunSystemInDir(log *ui.Task, dir string, args ...string) error { + log = log.SubTask("exec") + log.Debugf("%s", shellquote.Join(args...)) + b := &bytes.Buffer{} + w := io.MultiWriter(b, log) + cmd, err := SystemCommand(args...) + if err != nil { + return errors.WithStack(err) + } + cmd.Dir = dir + cmd.Stdout = w + cmd.Stderr = w + if err = cmd.Run(); err != nil { + if !log.WillLog(ui.LevelDebug) { + log.Errorf("%s", b.String()) + } + return errors.Wrapf(err, "%s failed", shellquote.Join(args...)) + } + return nil +} + // Command constructs a new exec.Cmd with logging configured. // // Returns the command, and a *bytes.Buffer containing the combined stdout and stderr diff --git a/util/run_test.go b/util/run_test.go new file mode 100644 index 00000000..74e444e3 --- /dev/null +++ b/util/run_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package util_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/cashapp/hermit/util" +) + +func TestSystemCommandIgnoresProcessPath(t *testing.T) { + attackerDir := t.TempDir() + attackerShell := filepath.Join(attackerDir, "sh") + assert.NoError(t, os.WriteFile(attackerShell, []byte("#!/bin/sh\necho attacker-controlled\n"), 0700)) + t.Setenv("PATH", attackerDir) + + cmd, err := util.SystemCommand("sh", "-c", `printf '%s' "$PATH"`) + assert.NoError(t, err) + assert.NotEqual(t, attackerShell, cmd.Path) + + out, err := cmd.Output() + assert.NoError(t, err) + assert.False(t, strings.Contains(string(out), attackerDir), "system helper inherited attacker-controlled PATH") +} + +func TestSystemCommandRejectsPaths(t *testing.T) { + path := filepath.Join(t.TempDir(), "git") + _, err := util.SystemCommand(path) + assert.EqualError(t, err, `system command must be a bare name: "`+path+`"`) +} From 2d816dab7f744a706b390874de832b3fd5f023f3 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Thu, 13 Aug 2026 13:59:21 +1000 Subject: [PATCH 2/2] Preserve host PATH for internal tools Restore PATH from Hermit's recorded activation operations instead of relying on fixed system directories. This keeps Homebrew, Nix, and other user-configured tool locations available while excluding repository-controlled paths injected by Hermit. Cover binary resolution independently before and after activation. Co-authored-by: Codex --- integration/integration_test.go | 80 ++++++++++++++++++++++++++------- util/run.go | 61 ++++++++++++++----------- util/run_test.go | 36 +++++++++++---- 3 files changed, 127 insertions(+), 50 deletions(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 91e1883c..8627234f 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -25,6 +25,8 @@ import ( "testing" "github.com/alecthomas/assert/v2" + "github.com/kballard/go-shellquote" + "github.com/cashapp/hermit/envars" "github.com/cashapp/hermit/errors" "github.com/creack/pty" @@ -172,26 +174,31 @@ EOF expectations: exp{outputContains("remote helpers are not supported")}, }, { - // Regression test for DX-29: Hermit's own helpers must not resolve from - // the environment bin directory that Hermit prepends to PATH. - name: "SystemHelpersIgnoreHermitBin", + // Regression test for DX-29: internal tools must still resolve from a + // user's custom PATH before a Hermit environment is activated. + name: "InternalToolsUseCurrentPathBeforeActivation", + preparations: prep{gitSourceWithHostGit()}, script: ` - SYSTEM_GIT=$(command -v git) - mkdir source.git - "$SYSTEM_GIT" init -q source.git - cat > source.git/safehelper.hcl <<'EOF' -description = "Package from a safely cloned source" -source = "https://example.com/safehelper-${version}" -version "1.0.0" {} + assert test "$(command -v git)" = "$HOST_GIT" + hermit init --no-git . + cat > bin/hermit.hcl < bin/hermit.hcl </dev/null || true rehash 2>/dev/null || true assert test "$(command -v git)" = "$PWD/bin/git" + rm -f HOST_GIT_USED.txt hermit search safehelper assert test ! -e RCE.txt + assert test -e HOST_GIT_USED.txt `, expectations: exp{outputContains("safehelper")}, }, @@ -1053,6 +1063,44 @@ func addFile(name, content string) preparation { } } +// gitSourceWithHostGit creates a local Git manifest source and a recording Git +// wrapper in a custom directory on the user's PATH. +func gitSourceWithHostGit() preparation { + return func(t *testing.T, dir string) string { + t.Helper() + git, err := exec.LookPath("git") + assert.NoError(t, err) + + sourceDir := filepath.Join(dir, "source.git") + assert.NoError(t, os.Mkdir(sourceDir, 0700)) + runGit := func(args ...string) { + cmd := exec.Command(git, args...) //nolint:noctx + output, err := cmd.CombinedOutput() + assert.NoError(t, err, "%s", output) + } + runGit("init", "-q", sourceDir) + assert.NoError(t, os.WriteFile(filepath.Join(sourceDir, "safehelper.hcl"), []byte(` +description = "Package from a safely cloned source" +source = "https://example.com/safehelper-${version}" +version "1.0.0" {} +`), 0600)) + runGit("-C", sourceDir, "add", "safehelper.hcl") + runGit("-C", sourceDir, + "-c", "user.name=Hermit", + "-c", "user.email=hermit@example.com", + "-c", "commit.gpgsign=false", + "commit", "-qm", "initial") + + hostBin := t.TempDir() + hostGit := filepath.Join(hostBin, "git") + wrapper := fmt.Sprintf("#!/bin/sh\ntouch %s\nexec %s \"$@\"\n", + shellquote.Join(filepath.Join(dir, "HOST_GIT_USED.txt")), shellquote.Join(git)) + assert.NoError(t, os.WriteFile(hostGit, []byte(wrapper), 0700)) + return fmt.Sprintf("export HOST_GIT=%s\nexport PATH=%s:\"$PATH\"", + shellquote.Join(hostGit), shellquote.Join(hostBin)) + } +} + // Copy a file from the testdata directory to the test directory. func copyFile(name string) preparation { return func(t *testing.T, dir string) string { diff --git a/util/run.go b/util/run.go index 8a7cedbf..8180a71f 100644 --- a/util/run.go +++ b/util/run.go @@ -10,6 +10,7 @@ import ( "github.com/kballard/go-shellquote" + "github.com/cashapp/hermit/envars" "github.com/cashapp/hermit/errors" "github.com/cashapp/hermit/ui" ) @@ -27,16 +28,10 @@ func (g *RealCommandRunner) RunInDir(task *ui.Task, dir string, commands ...stri return errors.WithStack(RunSystemInDir(task, dir, commands...)) } -// systemPath is the trusted path used for Hermit's own helper processes. In -// particular, it excludes Hermit environment bin directories, which may be -// controlled by the repository being operated on. -func systemPath() string { - return "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" -} - -// SystemCommand constructs a command for one of Hermit's own system helpers. -// The executable is resolved only from the system path, and the same path is -// inherited by the helper's child processes. +// SystemCommand constructs a command for an external tool used internally by +// Hermit. The executable is resolved from PATH with the active Hermit +// environment's changes reverted, and that same PATH is inherited by its +// child processes. func SystemCommand(args ...string) (*exec.Cmd, error) { if len(args) == 0 { return nil, errors.New("missing system command") @@ -45,8 +40,12 @@ func SystemCommand(args ...string) (*exec.Cmd, error) { if filepath.Base(name) != name { return nil, errors.Errorf("system command must be a bare name: %q", name) } + environ, err := systemEnviron() + if err != nil { + return nil, err + } var path string - for _, dir := range filepath.SplitList(systemPath()) { + for _, dir := range filepath.SplitList(environ["PATH"]) { candidate := filepath.Join(dir, name) info, err := os.Stat(candidate) if err == nil && !info.IsDir() && info.Mode().Perm()&0111 != 0 { @@ -58,21 +57,31 @@ func SystemCommand(args ...string) (*exec.Cmd, error) { return nil, &exec.Error{Name: name, Err: exec.ErrNotFound} } cmd := exec.Command(path, args[1:]...) //nolint:noctx - cmd.Env = systemEnviron() + cmd.Env = environ.System() return cmd, nil } -func systemEnviron() []string { - environ := os.Environ() - out := make([]string, 0, len(environ)+1) - for _, entry := range environ { - key, _, _ := strings.Cut(entry, "=") - if strings.EqualFold(key, "PATH") { - continue - } - out = append(out, entry) +// systemEnviron returns the current environment with PATH restored to its +// state before Hermit activation. All other environment variables are left +// unchanged. +func systemEnviron() (envars.Envars, error) { + environ := envars.Parse(os.Environ()) + data := os.Getenv("HERMIT_ENV_OPS") + if data == "" { + return environ, nil + } + ops, err := envars.UnmarshalOps([]byte(data)) + if err != nil { + return nil, errors.Wrap(err, "failed to restore PATH before Hermit activation") + } + reverted := environ.Revert(os.Getenv("HERMIT_ENV"), ops).Combined() + path, ok := reverted["PATH"] + if !ok { + delete(environ, "PATH") + } else { + environ["PATH"] = path } - return append(out, "PATH="+systemPath()) + return environ, nil } // Run a command, outputting to stdout and stderr. @@ -80,7 +89,7 @@ func Run(log *ui.Task, args ...string) error { return RunInDir(log, "", args...) } -// RunSystem runs one of Hermit's own system helpers. +// RunSystem runs an external tool used internally by Hermit. func RunSystem(log *ui.Task, args ...string) error { return RunSystemInDir(log, "", args...) } @@ -92,12 +101,12 @@ func Capture(log ui.Logger, args ...string) ([]byte, error) { return captureOutput(log, cmd) } -// CaptureSystem runs one of Hermit's own system helpers and returns its output. +// CaptureSystem runs an external tool used internally by Hermit and returns its output. func CaptureSystem(log ui.Logger, args ...string) ([]byte, error) { return CaptureSystemInDir(log, "", args...) } -// CaptureSystemInDir runs one of Hermit's own system helpers in the given dir +// CaptureSystemInDir runs an external tool used internally by Hermit in the given dir // and returns its output. func CaptureSystemInDir(log ui.Logger, dir string, args ...string) ([]byte, error) { log.Debugf("%s", shellquote.Join(args...)) @@ -141,7 +150,7 @@ func RunInDir(log *ui.Task, dir string, args ...string) error { return nil } -// RunSystemInDir runs one of Hermit's own system helpers in the given dir. +// RunSystemInDir runs an external tool used internally by Hermit in the given dir. func RunSystemInDir(log *ui.Task, dir string, args ...string) error { log = log.SubTask("exec") log.Debugf("%s", shellquote.Join(args...)) diff --git a/util/run_test.go b/util/run_test.go index 74e444e3..6c6d1043 100644 --- a/util/run_test.go +++ b/util/run_test.go @@ -9,22 +9,42 @@ import ( "testing" "github.com/alecthomas/assert/v2" + "github.com/cashapp/hermit/envars" "github.com/cashapp/hermit/util" ) -func TestSystemCommandIgnoresProcessPath(t *testing.T) { - attackerDir := t.TempDir() - attackerShell := filepath.Join(attackerDir, "sh") - assert.NoError(t, os.WriteFile(attackerShell, []byte("#!/bin/sh\necho attacker-controlled\n"), 0700)) - t.Setenv("PATH", attackerDir) +func TestSystemCommandRestoresPathBeforeHermitActivation(t *testing.T) { + hostDir := t.TempDir() + hostGit := filepath.Join(hostDir, "git") + assert.NoError(t, os.WriteFile(hostGit, []byte("#!/bin/sh\nprintf 'host:%s' \"$PATH\"\n"), 0700)) - cmd, err := util.SystemCommand("sh", "-c", `printf '%s' "$PATH"`) + envRoot := t.TempDir() + envBin := filepath.Join(envRoot, "bin") + assert.NoError(t, os.Mkdir(envBin, 0700)) + attackerGit := filepath.Join(envBin, "git") + assert.NoError(t, os.WriteFile(attackerGit, []byte("#!/bin/sh\nprintf attacker-controlled\n"), 0700)) + + ops, err := envars.MarshalOps(envars.Ops{&envars.Prepend{Name: "PATH", Value: envBin}}) + assert.NoError(t, err) + t.Setenv("HERMIT_ENV", envRoot) + t.Setenv("HERMIT_ENV_OPS", string(ops)) + t.Setenv("PATH", envBin+string(os.PathListSeparator)+hostDir) + + cmd, err := util.SystemCommand("git") assert.NoError(t, err) - assert.NotEqual(t, attackerShell, cmd.Path) + assert.Equal(t, hostGit, cmd.Path) out, err := cmd.Output() assert.NoError(t, err) - assert.False(t, strings.Contains(string(out), attackerDir), "system helper inherited attacker-controlled PATH") + assert.Equal(t, "host:"+hostDir, string(out)) +} + +func TestSystemCommandFailsClosedForInvalidHermitEnvOps(t *testing.T) { + t.Setenv("HERMIT_ENV_OPS", "not JSON") + + _, err := util.SystemCommand("git") + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "failed to restore PATH before Hermit activation")) } func TestSystemCommandRejectsPaths(t *testing.T) {