diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 23355b4f64..6d83d29a08 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "path" @@ -78,7 +79,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. // // For "msi", addMetadataToSoftwarePayload fails before this point if product code cannot be extracted. // - case payload.Extension == "exe" || payload.Extension == "tar.gz": + case payload.Extension == "exe" || payload.Extension == "tar.gz" || fleet.IsScriptPackage(payload.Extension): return nil, &fleet.BadRequestError{ Message: fmt.Sprintf("Couldn't add. Fleet can't create a policy to detect existing installations for .%s packages. Please add the software, add a custom policy, and enable the install software policy automation.", payload.Extension), } @@ -1537,6 +1538,16 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f return "", ctxerr.New(ctx, "installer file is required") } + ext := strings.ToLower(filepath.Ext(payload.Filename)) + ext = strings.TrimPrefix(ext, ".") + + if fleet.IsScriptPackage(ext) { + if err := svc.addScriptPackageMetadata(ctx, payload, ext); err != nil { + return "", err + } + return ext, nil + } + meta, err := file.ExtractInstallerMetadata(payload.InstallerFile) if err != nil { if errors.Is(err, file.ErrUnsupportedType) { @@ -1621,6 +1632,60 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f return meta.Extension, nil } +func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, extension string) error { + if payload == nil { + return ctxerr.New(ctx, "payload is required") + } + + if payload.InstallerFile == nil { + return ctxerr.New(ctx, "installer file is required") + } + + scriptBytes, err := io.ReadAll(payload.InstallerFile) + if err != nil { + return ctxerr.Wrap(ctx, err, "reading script file") + } + + if err := payload.InstallerFile.Rewind(); err != nil { + return ctxerr.Wrap(ctx, err, "resetting script file reader") + } + + scriptContents := string(scriptBytes) + + if err := fleet.ValidateHostScriptContents(scriptContents, false); err != nil { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", err.Error()), + InternalErr: ctxerr.Wrap(ctx, err, "validating script contents"), + } + } + + shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile) + if err != nil { + return ctxerr.Wrap(ctx, err, "calculating script SHA256") + } + + if payload.Title == "" { + base := filepath.Base(payload.Filename) + payload.Title = strings.TrimSuffix(base, filepath.Ext(base)) + } + + payload.Version = "" + payload.InstallScript = scriptContents + payload.StorageID = shaSum + payload.BundleIdentifier = "" + payload.PackageIDs = nil + payload.Extension = extension + payload.Source = "scripts" + + platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension) + if err != nil { + return ctxerr.Wrap(ctx, err, "determining platform from extension") + } + payload.Platform = platform + + return nil +} + const ( batchSoftwarePrefix = "software_batch_" ) diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 9127ab3b42..4b6421acf1 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -101,7 +101,7 @@ func TestInstallUninstallAuth(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ - ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}, // global scripts being disabled shouldn't impact (un)installs + ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}, }, nil } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { @@ -184,7 +184,6 @@ func TestInstallUninstallAuth(t *testing.T) { } } -// TestUninstallSoftwareTitle is mostly tested in enterprise integration test. This test hits a few edge cases. func TestUninstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) @@ -200,7 +199,6 @@ func TestUninstallSoftwareTitle(t *testing.T) { return host, nil } - // Global scripts disabled (doesn't matter) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ ServerSettings: fleet.ServerSettings{ @@ -209,12 +207,10 @@ func TestUninstallSoftwareTitle(t *testing.T) { }, nil } - // Host scripts disabled host.ScriptsEnabled = ptr.Bool(false) require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg) } -// TestInstallSoftwareTitle is mostly tested in enterprise integration test. This test hits is placed to hit some edge cases. func TestInstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) @@ -312,7 +308,6 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) - // when SHA256 is no_check ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, @@ -331,7 +326,6 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) - // when a slug empty, we no-op and return the payload as is payload = fleet.SoftwareInstallerPayload{URL: "https://fleetdm.com"} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) @@ -377,3 +371,155 @@ func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmoc } return svc, baseSvc } + +func TestAddScriptPackageMetadata(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newTestService(t, nil) + + t.Run("valid shell script", func(t *testing.T) { + scriptContents := "#!/bin/bash\necho 'Installing software'\n" + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") + require.NoError(t, err) + defer tmpFile.Close() + _, err = tmpFile.WriteString(scriptContents) + require.NoError(t, err) + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "install-app.sh", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "sh") + require.NoError(t, err) + require.Equal(t, "install-app", payload.Title) + require.Equal(t, "", payload.Version) + require.Equal(t, scriptContents, payload.InstallScript) + require.Equal(t, "linux", payload.Platform) + require.Equal(t, "scripts", payload.Source) + require.Empty(t, payload.BundleIdentifier) + require.Empty(t, payload.PackageIDs) + require.NotEmpty(t, payload.StorageID) + require.Equal(t, "sh", payload.Extension) + }) + + t.Run("valid powershell script", func(t *testing.T) { + scriptContents := "Write-Host 'Installing software'\n" + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.ps1") + require.NoError(t, err) + defer tmpFile.Close() + _, err = tmpFile.WriteString(scriptContents) + require.NoError(t, err) + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "install-app.ps1", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "ps1") + require.NoError(t, err) + require.Equal(t, "install-app", payload.Title) + require.Equal(t, "", payload.Version) + require.Equal(t, scriptContents, payload.InstallScript) + require.Equal(t, "windows", payload.Platform) + require.Equal(t, "scripts", payload.Source) + require.Empty(t, payload.BundleIdentifier) + require.Empty(t, payload.PackageIDs) + require.NotEmpty(t, payload.StorageID) + }) + + t.Run("invalid shebang", func(t *testing.T) { + scriptContents := "#!/usr/bin/python\nprint('hello')\n" + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") + require.NoError(t, err) + defer tmpFile.Close() + _, err = tmpFile.WriteString(scriptContents) + require.NoError(t, err) + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "test.sh", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "sh") + require.Error(t, err) + require.Contains(t, err.Error(), "Script validation failed") + require.Contains(t, err.Error(), "Interpreter not supported") + }) + + t.Run("empty script", func(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") + require.NoError(t, err) + defer tmpFile.Close() + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "test.sh", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "sh") + require.Error(t, err) + require.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("custom title preserved", func(t *testing.T) { + scriptContents := "#!/bin/bash\necho 'test'\n" + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") + require.NoError(t, err) + defer tmpFile.Close() + _, err = tmpFile.WriteString(scriptContents) + require.NoError(t, err) + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "test.sh", + Title: "My Custom Title", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "sh") + require.NoError(t, err) + require.Equal(t, "My Custom Title", payload.Title) + }) + + t.Run("file contents preserved verbatim", func(t *testing.T) { + scriptContents := "#!/bin/bash\necho \"Test's 'quotes'\"\necho $VAR\n\n" + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh") + require.NoError(t, err) + defer tmpFile.Close() + _, err = tmpFile.WriteString(scriptContents) + require.NoError(t, err) + + tfr, err := fleet.NewKeepFileReader(tmpFile.Name()) + require.NoError(t, err) + defer tfr.Close() + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallerFile: tfr, + Filename: "test.sh", + } + + err = svc.addScriptPackageMetadata(ctx, payload, "sh") + require.NoError(t, err) + require.Equal(t, scriptContents, payload.InstallScript) + }) +} diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 463763ec7b..adf58bd9e4 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -568,6 +568,11 @@ type DownloadSoftwareInstallerPayload struct { Size int64 } +func IsScriptPackage(ext string) bool { + ext = strings.TrimPrefix(ext, ".") + return ext == "sh" || ext == "ps1" +} + func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error) { ext = strings.TrimPrefix(ext, ".") switch ext { @@ -594,9 +599,9 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error func SoftwareInstallerPlatformFromExtension(ext string) (string, error) { ext = strings.TrimPrefix(ext, ".") switch ext { - case "deb", "rpm", "tar.gz": + case "deb", "rpm", "tar.gz", "sh": return "linux", nil - case "exe", "msi": + case "exe", "msi", "ps1": return "windows", nil case "pkg": return "darwin", nil