@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Add support for uploading and running zsh scripts on macOS and Linux hosts
|
||||
@@ -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)",
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Add support for executing zsh scripts on macOS and Linux hosts
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user