From 150318c87ea9a2ce051177297616262e60b2aec3 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:01:54 -0500 Subject: [PATCH] Add Python script support for macOS and Linux (#38562) This commit introduces support for Python (.py) scripts on macOS and Linux, including validation for Python shebangs and updates to documentation, UI, error messages, and backend validation logic. It also updates tests and file upload handling to recognize and properly process Python scripts alongside existing shell (.sh) and PowerShell (.ps1) scripts. **Related issue:** Resolves # # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [ ] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed ## Database migrations - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [ ] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [ ] Verified that any relevant UI is disabled when GitOps mode is enabled ## fleetd/orbit/Fleet Desktop - [ ] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [ ] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [ ] Verified that fleetd runs on macOS, Linux and Windows - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) --------- Co-authored-by: Jordan Montgomery Co-authored-by: melpike <79950145+melpike@users.noreply.github.com> Co-authored-by: jkatz01 Co-authored-by: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> --- changes/38793-python-scripts | 1 + cmd/fleetctl/fleetctl/scripts.go | 2 +- cmd/fleetctl/fleetctl/scripts_test.go | 4 +- ee/server/service/software_installers.go | 35 ++++ frontend/components/Editor/Editor.stories.tsx | 2 +- frontend/components/Editor/Editor.tsx | 1 + .../EditScriptModal/EditScriptModal.tsx | 7 +- .../ScriptListItem/ScriptListItem.tsx | 2 +- .../ScriptUploadModal/ScriptUploadModal.tsx | 16 +- .../components/ScriptUploadModal/helpers.ts | 4 +- .../ScriptUploader/ScriptUploader.tsx | 11 +- orbit/changes/38793-python-scripts | 1 + orbit/pkg/scripts/exec_nonwindows_test.go | 2 +- server/datastore/mysql/scripts.go | 22 ++- server/fleet/errors.go | 2 +- server/fleet/scripts.go | 157 ++++++++++++++++-- server/fleet/scripts_test.go | 32 +++- server/service/integration_enterprise_test.go | 8 +- 18 files changed, 265 insertions(+), 44 deletions(-) create mode 100644 changes/38793-python-scripts create mode 100644 orbit/changes/38793-python-scripts 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,