diff --git a/changes/38793-python-scripts b/changes/38793-python-scripts new file mode 100644 index 0000000000..175553a622 --- /dev/null +++ b/changes/38793-python-scripts @@ -0,0 +1 @@ +- Added support for running python scripts on macOS and Linux diff --git a/cmd/fleetctl/fleetctl/scripts.go b/cmd/fleetctl/fleetctl/scripts.go index e19de83d6c..aef9f3abf5 100644 --- a/cmd/fleetctl/fleetctl/scripts.go +++ b/cmd/fleetctl/fleetctl/scripts.go @@ -246,7 +246,7 @@ Output {{- if .ExecTimeout }} before timeout {{- end }}: func validateScriptPath(path string) error { extension := filepath.Ext(path) - if extension == ".sh" || extension == ".ps1" { + if extension == ".sh" || extension == ".ps1" || extension == ".py" { return nil } return errors.New(fleet.RunScriptInvalidTypeErrMsg) diff --git a/cmd/fleetctl/fleetctl/scripts_test.go b/cmd/fleetctl/fleetctl/scripts_test.go index edc1551045..049aa03f01 100644 --- a/cmd/fleetctl/fleetctl/scripts_test.go +++ b/cmd/fleetctl/fleetctl/scripts_test.go @@ -113,12 +113,12 @@ hello world { name: "invalid hashbang", scriptPath: func() string { return writeTmpScriptContents(t, "#! /foo/bar", ".sh") }, - expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh", "#!/bin/bash", or "#!/bin/zsh."`, + expectErrMsg: `Interpreter not supported. Supported interpreters are "#!/bin/sh", "#!/bin/bash", "#!/bin/zsh", "#!/usr/bin/env python3", or an absolute path to "python" / "python3".`, }, { name: "unsupported hashbang", scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/ksh", ".sh") }, - expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh", "#!/bin/bash", or "#!/bin/zsh."`, + expectErrMsg: `Interpreter not supported. Supported interpreters are "#!/bin/sh", "#!/bin/bash", "#!/bin/zsh", "#!/usr/bin/env python3", or an absolute path to "python" / "python3".`, }, { name: "posix shell hashbang", diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index a62f3d2fb2..862d378947 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1908,6 +1908,41 @@ func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet } } + // Validate that the shebang matches the file extension + kind, directExecute, err := fleet.ShebangInfo(scriptContents) + if err != nil { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", err.Error()), + InternalErr: ctxerr.Wrap(ctx, err, "validating script shebang"), + } + } + switch extension { + case "sh": + // allow no shebang (defaults to /bin/sh), or a supported shell shebang. + if directExecute && kind != fleet.ShebangShell { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", fleet.ErrUnsupportedInterpreter.Error()), + InternalErr: ctxerr.New(ctx, "shell script with non-shell shebang"), + } + } + case "py": + // python scripts must be directly executable (via a python shebang). + if !directExecute || kind != fleet.ShebangPython { + return &fleet.BadRequestError{ + Message: "Couldn't add. Script validation failed: Python scripts must start with a python shebang (for example, \"#!/usr/bin/env python3\").", + InternalErr: ctxerr.New(ctx, "python script without python shebang"), + } + } + case "ps1": + // PowerShell scripts are executed via powershell.exe, shebangs are not supported. + if directExecute { + return &fleet.BadRequestError{ + Message: "Couldn't add. Script validation failed: PowerShell scripts must not start with a shebang (\"#!\").", + InternalErr: ctxerr.New(ctx, "powershell script with shebang"), + } + } + } + shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile) if err != nil { return ctxerr.Wrap(ctx, err, "calculating script SHA256") diff --git a/frontend/components/Editor/Editor.stories.tsx b/frontend/components/Editor/Editor.stories.tsx index cc516fbc86..d4c9b77773 100644 --- a/frontend/components/Editor/Editor.stories.tsx +++ b/frontend/components/Editor/Editor.stories.tsx @@ -9,7 +9,7 @@ const meta: Meta = { argTypes: { mode: { control: "select", - options: ["sh", "powershell"], + options: ["sh", "python", "powershell"], description: "Syntax highlighting mode", }, readOnly: { control: "boolean" }, diff --git a/frontend/components/Editor/Editor.tsx b/frontend/components/Editor/Editor.tsx index 5f8a3680f7..1355df24ac 100644 --- a/frontend/components/Editor/Editor.tsx +++ b/frontend/components/Editor/Editor.tsx @@ -4,6 +4,7 @@ import classnames from "classnames"; import AceEditor from "react-ace"; import "ace-builds/src-noconflict/mode-sh"; import "ace-builds/src-noconflict/mode-powershell"; +import "ace-builds/src-noconflict/mode-python"; import { Ace } from "ace-builds"; import { stringToClipboard } from "utilities/copy_text"; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx index 226bf3cb11..ab8a184010 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx @@ -177,7 +177,12 @@ const EditScriptModal = ({ } // Set editing mode based on the file extension. - const mode = scriptName.match(/\.sh$/) ? "sh" : "powershell"; + let mode = "sh"; + if (scriptName.match(/\.ps1$/)) { + mode = "powershell"; + } else if (scriptName.match(/\.py$/)) { + mode = "python"; + } return ( <> diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx index e97aac29c1..66c9fde2dc 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx @@ -31,7 +31,7 @@ const getFileRenderDetails = ( switch (fileExtension) { case "py": - return { graphicName: "file-py", platform: null }; + return { graphicName: "file-py", platform: "macOS & Linux" }; case "sh": return { graphicName: "file-sh", platform: "macOS & Linux" }; case "ps1": diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx index 90c13e06ec..f37bfb37c2 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx @@ -40,10 +40,18 @@ const ScriptUploadModal = ({ } }; - const additionalInfo = - selectedFile && selectedFile.name.match(/\.sh$/) - ? 'On macOS and Linux, script will run according to the interpreter specified in the first line: "#!/bin/sh", "#!/bin/zsh", or "#!/bin/bash"' - : undefined; + const additionalInfo = (() => { + if (!selectedFile) { + return undefined; + } + if (selectedFile.name.match(/\.sh$/)) { + return 'On macOS and Linux, script will run according to the interpreter specified in the first line: "#!/bin/sh", "#!/bin/zsh", or "#!/bin/bash"'; + } + if (selectedFile.name.match(/\.py$/)) { + return 'On macOS and Linux, Python scripts must start with a python shebang in the first line (for example, "#!/usr/bin/env python3" or "#!/usr/bin/python3").'; + } + return undefined; + })(); return ( { if ( apiErrMessage.includes( - "File type not supported. Only .sh and .ps1 file type is allowed" + "File type not supported. Only .sh, .py, and .ps1 file types are allowed" ) ) { - return "Couldn't add. The file should be .sh or .ps1 file."; + return "Couldn't add. The file should be a .sh, .py, or .ps1 file."; } else if (apiErrMessage.includes("Secret variable")) { return generateSecretErrMsg(err); } diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx index 5ee3ce3984..557af5ce98 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploader/ScriptUploader.tsx @@ -26,26 +26,29 @@ const ScriptPackageUploader = ({ const buttonType = forModal ? "brand-inverse-icon" : undefined; const buttonMessage = forModal ? "Choose file" : "Add script"; - const extension = selectedFile?.name.match(/(sh|ps1)$/i)?.[1]; + const extension = selectedFile?.name.match(/(sh|py|ps1)$/i)?.[1]; let graphicName: ISupportedGraphicNames[]; switch (extension) { case "ps1": graphicName = ["file-ps1"]; break; + case "py": + graphicName = ["file-py"]; + break; case "sh": graphicName = ["file-sh"]; break; default: - graphicName = ["file-sh", "file-ps1"]; + graphicName = ["file-sh", "file-py", "file-ps1"]; } return ( 0 { - args = append(args, extension) + if len(extensionPatterns) > 0 { + likeClauses := make([]string, 0, len(extensionPatterns)) + for _, ext := range extensionPatterns { + likeClauses = append(likeClauses, "s.name LIKE ?") + args = append(args, ext) + } sql += ` - AND s.name LIKE ? + AND ( + ` + strings.Join(likeClauses, ` + OR + `) + ` + ) ` } stmt, args := appendListOptionsWithCursorToSQL(sql, args, &opt) diff --git a/server/fleet/errors.go b/server/fleet/errors.go index 9e921a53b6..c7bec7cfb3 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -492,7 +492,7 @@ const ( TargetedHostsDontExistErrMsg = "One or more targeted hosts don't exist. Make sure you provide a valid hostname, UUID, or serial number. Learn more about host identifiers: https://fleetdm.com/learn-more-about/host-identifiers" // Scripts - RunScriptInvalidTypeErrMsg = "File type not supported. Only .sh (Bash) and .ps1 (PowerShell) file types are allowed." + RunScriptInvalidTypeErrMsg = "File type not supported. Only .sh (Shell), .py (Python), and .ps1 (PowerShell) file types are allowed." RunScriptHostOfflineErrMsg = "Script can't run on offline host." RunScriptForbiddenErrMsg = "You don't have the right permissions in Fleet to run the script." RunScriptAlreadyRunningErrMsg = "A script is already running on this host. Please wait about 5 minutes to let it finish." diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 81d96287e4..f63dfe92d7 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -39,8 +39,13 @@ func (s *Script) ValidateNewScript() error { if s.Name == "" { return errors.New("The file name must not be empty.") } - if filepath.Ext(s.Name) != ".sh" && filepath.Ext(s.Name) != ".ps1" { - return errors.New("File type not supported. Only .sh and .ps1 file type is allowed.") + + ext := strings.ToLower(filepath.Ext(s.Name)) + switch ext { + case ".sh", ".ps1", ".py": + // ok + default: + return errors.New("File type not supported. Only .sh, .py, and .ps1 file types are allowed.") } // validate the script contents as if it were already a saved script @@ -48,6 +53,28 @@ func (s *Script) ValidateNewScript() error { return err } + kind, directExecute, err := shebangInfo(s.ScriptContents) + if err != nil { + return err + } + switch ext { + case ".sh": + // allow no shebang (defaults to /bin/sh), or a supported shell shebang. + if directExecute && kind != shebangShell { + return errors.New(`Shell scripts must use a shell shebang (for example, "#!/bin/sh") or no shebang. For Python, use a ".py" script.`) + } + case ".py": + // python scripts must be directly executable (via a python shebang). + if !directExecute || kind != shebangPython { + return errors.New(`Python scripts must start with a python shebang (for example, "#!/usr/bin/env python3").`) + } + case ".ps1": + // PowerShell scripts are executed via powershell.exe, shebangs are not supported. + if directExecute { + return errors.New(`PowerShell scripts must not start with a shebang ("#!").`) + } + } + return nil } @@ -361,22 +388,124 @@ const ( // anchored, so that it matches to the end of the line var ( scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/(ba|z)?sh(?:\s*|\s+.*)$`) - ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Shell scripts must run in "#!/bin/sh", "#!/bin/bash", or "#!/bin/zsh."`) + ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Supported interpreters are "#!/bin/sh", "#!/bin/bash", "#!/bin/zsh", "#!/usr/bin/env python3", or an absolute path to "python" / "python3".`) ) +type ShebangKind int + +const ( + ShebangNone ShebangKind = iota + ShebangShell + ShebangPython +) + +// shebangKind is kept for internal use to maintain backwards compatibility +type shebangKind = ShebangKind + +const ( + shebangNone = ShebangNone + shebangShell = ShebangShell + shebangPython = ShebangPython +) + +// ShebangInfo inspects the script contents and returns whether it should be +// executed directly (via the kernel's shebang support), and what kind of +// interpreter it declares. +// +// Note: for backwards compatibility, scripts without a shebang are allowed and +// will be executed using /bin/sh. +func ShebangInfo(contents string) (kind ShebangKind, directExecute bool, err error) { + return shebangInfo(contents) +} + +// shebangInfo inspects the script contents and returns whether it should be +// executed directly (via the kernel's shebang support), and what kind of +// interpreter it declares. +// +// Note: for backwards compatibility, scripts without a shebang are allowed and +// will be executed using /bin/sh. +func shebangInfo(contents string) (kind shebangKind, directExecute bool, err error) { + if !strings.HasPrefix(contents, "#!") { + return shebangNone, false, nil + } + + // read the first line in a portable way + sc := bufio.NewScanner(strings.NewReader(contents)) + if !sc.Scan() { + return shebangNone, false, ErrUnsupportedInterpreter + } + line := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(line, "#!") { + // should not happen given the prefix check, but be defensive + return shebangNone, false, nil + } + + // tokenize the shebang: "#! [args...]" + rest := strings.TrimSpace(strings.TrimPrefix(line, "#!")) + fields := strings.Fields(rest) + if len(fields) == 0 { + return shebangNone, false, ErrUnsupportedInterpreter + } + + interp := fields[0] + base := filepath.Base(interp) + + // Support env-based shebangs like "#!/usr/bin/env python3" + if base == "env" { + // Require env to be in /bin or /usr/bin (common, predictable locations) + if !strings.HasPrefix(interp, "/bin/") && !strings.HasPrefix(interp, "/usr/bin/") { + return shebangNone, false, ErrUnsupportedInterpreter + } + + // Skip env options (e.g. -S, -i) until we find the command. + i := 1 + for i < len(fields) && strings.HasPrefix(fields[i], "-") { + i++ + } + if i >= len(fields) { + return shebangNone, false, ErrUnsupportedInterpreter + } + cmd := fields[i] + switch { + case cmd == "sh" || cmd == "bash" || cmd == "zsh": + return shebangShell, true, nil + case cmd == "python" || cmd == "python3" || strings.HasPrefix(cmd, "python3."): + return shebangPython, true, nil + default: + return shebangNone, false, ErrUnsupportedInterpreter + } + } + + // For direct interpreter paths, require an absolute path. For shell scripts, + // we keep the historical restriction to /bin or /usr/bin. For Python, allow + // any absolute path (e.g. /usr/local/bin/python3, /opt/homebrew/bin/python3). + if !strings.HasPrefix(interp, "/") { + return shebangNone, false, ErrUnsupportedInterpreter + } + + switch { + case base == "sh" || base == "bash" || base == "zsh": + if !strings.HasPrefix(interp, "/bin/") && !strings.HasPrefix(interp, "/usr/bin/") { + return shebangNone, false, ErrUnsupportedInterpreter + } + return shebangShell, true, nil + case base == "python" || base == "python3" || strings.HasPrefix(base, "python3."): + return shebangPython, true, nil + default: + // preserve backwards-compatibility with prior behavior for shell scripts + // that relied on the regex validator (primarily for /usr/bin/(ba|z)?sh). + if scriptHashbangValidation.MatchString(line) { + return shebangShell, true, nil + } + return shebangNone, false, ErrUnsupportedInterpreter + } +} + // 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 `(/usr)/bin/sh`, `(/usr)/bin/bash`, `(/usr)/bin/zsh` for now - if s.Scan() && !scriptHashbangValidation.MatchString(s.Text()) { - return false, ErrUnsupportedInterpreter - } - return true, nil - } - return false, nil + _, directExecute, err = shebangInfo(s) + return directExecute, err } func ValidateHostScriptContents(s string, isSavedScript bool) error { @@ -704,6 +833,8 @@ func ValidateScriptPlatform(scriptName, platform string) bool { switch filepath.Ext(scriptName) { case ".sh": return IsUnixLike(platform) + case ".py": + return IsUnixLike(platform) case ".ps1": return platform == "windows" default: diff --git a/server/fleet/scripts_test.go b/server/fleet/scripts_test.go index ee06281692..9e1159f2f2 100644 --- a/server/fleet/scripts_test.go +++ b/server/fleet/scripts_test.go @@ -25,6 +25,14 @@ func TestScriptValidate(t *testing.T) { }, wantErr: nil, }, + { + name: "valid python script", + script: Script{ + Name: "test.py", + ScriptContents: "#!/usr/bin/env python3\nprint('hi')", + }, + wantErr: nil, + }, { name: "empty name", script: Script{ @@ -39,7 +47,7 @@ func TestScriptValidate(t *testing.T) { Name: "test.txt", ScriptContents: "valid", }, - wantErr: errors.New("File type not supported. Only .sh and .ps1 file type is allowed."), + wantErr: errors.New("File type not supported. Only .sh, .py, and .ps1 file types are allowed."), }, { name: "invalid script content", @@ -91,9 +99,24 @@ func TestValidateShebang(t *testing.T) { contents: "#!/bin/zsh -x\necho hi", directExecute: true, }, + { + name: "python3 env shebang", + contents: "#!/usr/bin/env python3\nprint('hi')", + directExecute: true, + }, + { + name: "python3 direct shebang", + contents: "#!/usr/bin/python3\nprint('hi')", + directExecute: true, + }, + { + name: "python3 env shebang with args", + contents: "#!/usr/bin/env python3 -u\nprint('hi')", + directExecute: true, + }, { name: "shebang with unsupported interpreter", - contents: "#!/usr/bin/python\nprint('hi')", + contents: "#!/bin/ksh\necho hi", directExecute: false, err: ErrUnsupportedInterpreter, }, @@ -176,6 +199,11 @@ func TestValidateHostScriptContents(t *testing.T) { script: "#!/usr/bin/zsh\necho 'hello'", wantErr: nil, }, + { + name: "valid python script", + script: "#!/usr/bin/env python3\nprint('hello')", + wantErr: nil, + }, } for _, tt := range tests { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 5a434a2145..c77acb7342 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8553,12 +8553,12 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() { errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "$FLEET_SECRET_INVALID") - // file name is not .sh + // file name is not a supported script type body, headers = generateNewScriptMultipartRequest(t, "not_sh.txt", []byte(`echo "hello"`), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.") + require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh, .py, and .ps1 file types are allowed.") // file content is empty body, headers = generateNewScriptMultipartRequest(t, @@ -8574,12 +8574,12 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() { errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Script is too large. It's limited to 500,000 characters") - // invalid hashbang + // python shebang in .sh script should be rejected body, headers = generateNewScriptMultipartRequest(t, "script2.sh", []byte(`#!/bin/python`), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Interpreter not supported.") + require.Contains(t, errMsg, "Shell scripts must use a shell shebang") // script already exists with this name for this no-team body, headers = generateNewScriptMultipartRequest(t,