Zsh script support (#18411)

#17321
This commit is contained in:
Dante Catalfamo
2024-04-30 14:38:56 -04:00
committed by GitHub
parent a9f79eacd4
commit 2c6e7c71a8
11 changed files with 223 additions and 17 deletions
+4 -1
View File
@@ -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
path: /tmp/summary.txt
+1
View File
@@ -0,0 +1 @@
* Add support for uploading and running zsh scripts on macOS and Linux hosts
+42 -1
View File
@@ -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)",
@@ -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}
+1
View File
@@ -0,0 +1 @@
* Add support for executing zsh scripts on macOS and Linux hosts
+22
View File
@@ -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 {
+73
View File
@@ -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)
})
}
}
+1 -3
View File
@@ -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)
}
+19 -8
View File
@@ -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
+55 -1
View File
@@ -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 {
+4 -2
View File
@@ -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})