diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index eb82af7e21..bdae1ea90d 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -79,6 +79,9 @@ jobs: - name: Install Go Dependencies run: make deps-go + - name: Install ZSH + run: sudo apt update && sudo apt install -y zsh + - name: Generate static files run: | export PATH=$PATH:~/go/bin @@ -157,4 +160,4 @@ jobs: uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 with: name: summary-test-log - path: /tmp/summary.txt \ No newline at end of file + path: /tmp/summary.txt diff --git a/changes/17321-zsh-support b/changes/17321-zsh-support new file mode 100644 index 0000000000..9ade50c170 --- /dev/null +++ b/changes/17321-zsh-support @@ -0,0 +1 @@ +* Add support for uploading and running zsh scripts on macOS and Linux hosts diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index aaeac54c2e..bc08c395e1 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -105,7 +105,48 @@ hello world { name: "invalid hashbang", scriptPath: func() string { return writeTmpScriptContents(t, "#! /foo/bar", ".sh") }, - expectErrMsg: `Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`, + expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`, + }, + { + name: "unsupported hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/ksh", ".sh") }, + expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`, + }, + { + name: "posix shell hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/sh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "zsh hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/zsh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "usr zsh hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/usr/bin/zsh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "zsh hashbang with arguments", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/zsh -x", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, }, { name: "script too long (unsaved)", diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx index af11a4639b..0614e4a7a5 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx @@ -55,7 +55,7 @@ const ScriptPackageUploader = ({ className={baseClass} graphicName={["file-sh", "file-ps1"]} message="Shell (.sh) for macOS and Linux or PowerShell (.ps1) for Windows" - additionalInfo="Script will run with “#!/bin/sh”on macOS and Linux." + additionalInfo="Script will run with “#!/bin/sh” or “#!/bin/zsh” on macOS and Linux." accept=".sh,.ps1" onFileUpload={onUploadFile} isLoading={showLoading} diff --git a/orbit/changes/17321-zsh-support b/orbit/changes/17321-zsh-support new file mode 100644 index 0000000000..7b2c37455f --- /dev/null +++ b/orbit/changes/17321-zsh-support @@ -0,0 +1 @@ +* Add support for executing zsh scripts on macOS and Linux hosts diff --git a/orbit/pkg/scripts/exec_nonwindows.go b/orbit/pkg/scripts/exec_nonwindows.go index 1d8c06f8b6..158333a17f 100644 --- a/orbit/pkg/scripts/exec_nonwindows.go +++ b/orbit/pkg/scripts/exec_nonwindows.go @@ -4,15 +4,37 @@ package scripts import ( "context" + "os" "os/exec" "path/filepath" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" ) func execCmd(ctx context.Context, scriptPath string) (output []byte, exitCode int, err error) { // initialize to -1 in case the process never starts exitCode = -1 + contents, err := os.ReadFile(scriptPath) + if err != nil { + return nil, -1, ctxerr.Wrapf(ctx, err, "opening script for validation %s", scriptPath) + } + directExecute, err := fleet.ValidateShebang(string(contents)) + if err != nil { + return nil, -1, ctxerr.Wrapf(ctx, err, "validating script %s", scriptPath) + } + cmd := exec.CommandContext(ctx, "/bin/sh", scriptPath) + + if directExecute { + err = os.Chmod(scriptPath, 0766) + if err != nil { + return nil, -1, ctxerr.Wrapf(ctx, err, "marking script as executable %s", scriptPath) + } + cmd = exec.CommandContext(ctx, scriptPath) + } + cmd.Dir = filepath.Dir(scriptPath) output, err = cmd.CombinedOutput() if cmd.ProcessState != nil { diff --git a/orbit/pkg/scripts/exec_nonwindows_test.go b/orbit/pkg/scripts/exec_nonwindows_test.go new file mode 100644 index 0000000000..52a5c069c0 --- /dev/null +++ b/orbit/pkg/scripts/exec_nonwindows_test.go @@ -0,0 +1,73 @@ +//go:build !windows + +package scripts + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestExecCmdNonWindows(t *testing.T) { + zshPath := "/bin/zsh" + if runtime.GOOS == "linux" { + zshPath = "/usr/bin/zsh" + } + + tests := []struct { + name string + contents string + output string + exitCode int + error error + }{ + { + name: "no shebang", + contents: "[ -z \"$ZSH_VERSION\" ] && echo 1", + output: "1", + }, + { + name: "sh shebang", + contents: "#!/bin/sh\n[ -z \"$ZSH_VERSION\" ] && echo 1", + output: "1", + }, + { + name: "zsh shebang", + contents: "#!" + zshPath + "\n[ -n \"$ZSH_VERSION\" ] && echo 1", + output: "1", + }, + { + name: "zsh shebang with args", + contents: "#!" + zshPath + " -e\n[ -n \"$ZSH_VERSION\" ] && echo 1", + output: "1", + }, + { + name: "unsupported shebang", + contents: "#!/bin/python", + error: fleet.ErrUnsupportedInterpreter, + exitCode: -1, + }, + } + + tmpDir := t.TempDir() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + scriptPath := strings.ReplaceAll(tc.name, " ", "_") + ".sh" + scriptPath = filepath.Join(tmpDir, scriptPath) + err := os.WriteFile(scriptPath, []byte(tc.contents), os.ModePerm) + require.NoError(t, err) + + output, exitCode, err := execCmd(context.Background(), scriptPath) + require.Equal(t, tc.output, strings.TrimSpace(string(output))) + require.Equal(t, tc.exitCode, exitCode) + require.ErrorIs(t, err, tc.error) + }) + } +} diff --git a/orbit/pkg/scripts/scripts.go b/orbit/pkg/scripts/scripts.go index 51efeb9c22..547af0fd40 100644 --- a/orbit/pkg/scripts/scripts.go +++ b/orbit/pkg/scripts/scripts.go @@ -103,13 +103,11 @@ func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) { }() } - ext := ".sh" + var ext string if runtime.GOOS == "windows" { ext = ".ps1" } scriptFile := filepath.Join(runDir, "script"+ext) - // the file does not need the executable bit set, it will be executed as - // argument to powershell or /bin/sh. if err := os.WriteFile(scriptFile, []byte(script.ScriptContents), constant.DefaultFileMode); err != nil { return fmt.Errorf("write script file: %w", err) } diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 0bfd620fbb..3bffbcef32 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -297,7 +297,23 @@ const ( ) // anchored, so that it matches to the end of the line -var scriptHashbangValidation = regexp.MustCompile(`^#!\s*/bin/sh\s*$`) +var scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/z?sh(?:\s*|\s+.*)$`) +var ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`) + +// ValidateShebang validates if we support a script, and whether we +// can execute it directly, or need to pass it to a shell interpreter. +func ValidateShebang(s string) (directExecute bool, err error) { + if strings.HasPrefix(s, "#!") { + // read the first line in a portable way + s := bufio.NewScanner(strings.NewReader(s)) + // if a hashbang is present, it can only be `/bin/sh` or `(/usr)/bin/zsh` for now + if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) { + return false, ErrUnsupportedInterpreter + } + return true, nil + } + return false, nil +} func ValidateHostScriptContents(s string, isSavedScript bool) error { if s == "" { @@ -330,13 +346,8 @@ func ValidateHostScriptContents(s string, isSavedScript bool) error { return errors.New("Wrong data format. Only plain text allowed.") } - if strings.HasPrefix(s, "#!") { - // read the first line in a portable way - s := bufio.NewScanner(strings.NewReader(s)) - // if a hashbang is present, it can only be `/bin/sh` for now - if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) { - return errors.New(`Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`) - } + if _, err := ValidateShebang(s); err != nil { + return err } return nil diff --git a/server/fleet/scripts_test.go b/server/fleet/scripts_test.go index 1587c86d01..e872eb1748 100644 --- a/server/fleet/scripts_test.go +++ b/server/fleet/scripts_test.go @@ -58,6 +58,50 @@ func TestScriptValidate(t *testing.T) { } } +func TestValidateShebang(t *testing.T) { + tests := []struct { + name string + contents string + directExecute bool + err error + }{ + { + name: "no shebang", + contents: "echo hi", + directExecute: false, + }, + { + name: "posix shebang", + contents: "#!/bin/sh\necho hi", + directExecute: true, + }, + { + name: "zsh shebang", + contents: "#!/bin/zsh\necho hi", + directExecute: true, + }, + { + name: "zsh shebang with args", + contents: "#!/bin/zsh -x\necho hi", + directExecute: true, + }, + { + name: "shebang with unsupported interpreter", + contents: "#!/usr/bin/python\nprint('hi')", + directExecute: false, + err: ErrUnsupportedInterpreter, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + directExecute, err := ValidateShebang(tc.contents) + require.Equal(t, tc.directExecute, directExecute) + require.ErrorIs(t, tc.err, err) + + }) + } +} + func TestValidateHostScriptContents(t *testing.T) { tests := []struct { name string @@ -100,13 +144,23 @@ func TestValidateHostScriptContents(t *testing.T) { { name: "unsupported interpreter", script: "#!/bin/bash\necho 'hello'", - wantErr: errors.New(`Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`), + wantErr: ErrUnsupportedInterpreter, }, { name: "valid script", script: "#!/bin/sh\necho 'hello'", wantErr: nil, }, + { + name: "valid zsh script", + script: "#!/bin/zsh\necho 'hello'", + wantErr: nil, + }, + { + name: "valid zsh script", + script: "#!/usr/bin/zsh\necho 'hello'", + wantErr: nil, + }, } for _, tt := range tests { diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go index 60cb72a11e..98d7e2dfb8 100644 --- a/server/service/scripts_test.go +++ b/server/service/scripts_test.go @@ -309,11 +309,13 @@ func TestHostRunScript(t *testing.T) { {"large script", strings.Repeat("a", fleet.UnsavedScriptMaxRuneLen), ""}, {"invalid utf8", "\xff\xfa", "Wrong data format."}, {"valid without hashbang", "echo 'a'", ""}, - {"valid with hashbang", "#!/bin/sh\necho 'a'", ""}, + {"valid with posix hashbang", "#!/bin/sh\necho 'a'", ""}, + {"valid with usr zsh hashbang", "#!/usr/bin/zsh\necho 'a'", ""}, + {"valid with zsh hashbang", "#!/bin/zsh\necho 'a'", ""}, + {"valid with zsh hashbang and arguments", "#!/bin/zsh -x\necho 'a'", ""}, {"valid with hashbang and spacing", "#! /bin/sh \necho 'a'", ""}, {"valid with hashbang and Windows newline", "#! /bin/sh \r\necho 'a'", ""}, {"invalid hashbang", "#!/bin/bash\necho 'a'", "Interpreter not supported."}, - {"invalid hashbang suffix", "#!/bin/sh -n\necho 'a'", "Interpreter not supported."}, } ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin})