package service import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" ma "github.com/fleetdm/fleet/v4/ee/maintained-apps" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/s3" "github.com/fleetdm/fleet/v4/server/dev_mode" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mock" svcmock "github.com/fleetdm/fleet/v4/server/mock/service" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPreProcessUninstallScript(t *testing.T) { t.Parallel() input := ` blah$PACKAGE_IDS pkgids=$PACKAGE_ID they are $PACKAGE_ID, right $MY_SECRET? quotes for "$PACKAGE_ID" blah${PACKAGE_ID}withConcat quotes and braces for "${PACKAGE_ID}" ${PACKAGE_ID}` payload := fleet.UploadSoftwareInstallerPayload{ Extension: "exe", UninstallScript: input, PackageIDs: []string{"com.foo"}, } require.NoError(t, preProcessUninstallScript(&payload)) expected := ` blah$PACKAGE_IDS pkgids="com.foo" they are "com.foo", right $MY_SECRET? quotes for "com.foo" blah"com.foo"withConcat quotes and braces for "com.foo" "com.foo"` assert.Equal(t, expected, payload.UninstallScript) payload = fleet.UploadSoftwareInstallerPayload{ Extension: "pkg", UninstallScript: input, PackageIDs: []string{"com.foo", "com.bar"}, } require.NoError(t, preProcessUninstallScript(&payload)) expected = ` blah$PACKAGE_IDS pkgids=( "com.foo" "com.bar" ) they are ( "com.foo" "com.bar" ), right $MY_SECRET? quotes for ( "com.foo" "com.bar" ) blah( "com.foo" "com.bar" )withConcat quotes and braces for ( "com.foo" "com.bar" ) ( "com.foo" "com.bar" )` assert.Equal(t, expected, payload.UninstallScript) payload.UninstallScript = "$UPGRADE_CODE" require.Error(t, preProcessUninstallScript(&payload)) payload.UpgradeCode = "foo" require.NoError(t, preProcessUninstallScript(&payload)) assert.Equal(t, `"foo"`, payload.UninstallScript) } func TestInstallUninstallAuth(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}, }, nil } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), }, nil } ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool, ) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ Name: "installer.pkg", Platform: "darwin", TeamID: ptr.Uint(1), }, nil } ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error, ) { return "request_id", nil } ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("script"), nil } ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint, selfService bool) error { return nil } ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } testCases := []struct { name string user *fleet.User shouldFail bool }{ { "global admin", &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, }, { "global maintainer", &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, false, }, { "global observer", &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, true, }, { "team admin", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, false, }, { "team maintainer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, false, }, { "team observer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user}) checkAuthErr(t, tt.shouldFail, svc.InstallSoftwareTitle(ctx, 1, 10)) checkAuthErr(t, tt.shouldFail, svc.UninstallSoftwareTitle(ctx, 1, 10)) }) } } func TestUninstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) host := &fleet.Host{ OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return host, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ ServerSettings: fleet.ServerSettings{ ScriptsDisabled: true, }, }, nil } host.ScriptsEnabled = ptr.Bool(false) require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg) } func TestInstallSoftwareTitle(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) host := &fleet.Host{ UUID: "personal-ios", OrbitNodeKey: ptr.String("orbit_key"), Platform: "ios", TeamID: ptr.Uint(1), } ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return host, nil } ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, id string) (*fleet.NanoEnrollment, error) { return &fleet.NanoEnrollment{ Type: mdm.EnrollType(mdm.UserEnrollmentDevice).String(), }, nil } require.ErrorContains(t, svc.InstallSoftwareTitle(ctx, 1, 10), fleet.InstallSoftwarePersonalAppleDeviceErrMsg) } func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { ds := new(mock.Store) svc := newTestService(t, ds) installerBytes := []byte("1password") h := sha256.New() _, err := h.Write(installerBytes) require.NoError(t, err) onePasswordSHA := hex.EncodeToString(h.Sum(nil)) manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slug := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, ".json"), "/") var versions []*ma.FMAManifestApp versions = append(versions, &ma.FMAManifestApp{ Version: "1", Queries: ma.FMAQueries{ Exists: "SELECT 1 FROM osquery_info;", }, InstallerURL: fmt.Sprintf("/installer-%s.zip", slug), InstallScriptRef: "installscript", UninstallScriptRef: "uninstallscript", DefaultCategories: []string{"Productivity"}, }) manifest := ma.FMAManifestFile{ Versions: versions, Refs: map[string]string{ "installscript": "echo 'installing'", "uninstallscript": "echo 'uninstalling'", }, } switch slug { case "": w.WriteHeader(http.StatusNotFound) return case "1password/darwin": manifest.Versions[0].SHA256 = onePasswordSHA case "google-chrome/darwin": manifest.Versions[0].SHA256 = "no_check" } err := json.NewEncoder(w).Encode(manifest) require.NoError(t, err) })) t.Cleanup(manifestServer.Close) dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t) ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "1Password", Platform: "darwin", UniqueIdentifier: "com.1password.1password", Slug: "1password/darwin", }, nil } payload := fleet.SoftwareInstallerPayload{Slug: ptr.String("1password/darwin")} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.NotEmpty(t, payload.URL) assert.Equal(t, onePasswordSHA, payload.SHA256) assert.NotEmpty(t, payload.InstallScript) assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{ ID: 1, Name: "Google Chrome", Platform: "darwin", UniqueIdentifier: "com.google.Chrome", Slug: "google-chrome/darwin", }, nil } payload = fleet.SoftwareInstallerPayload{Slug: ptr.String("google-chrome/darwin")} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.NotEmpty(t, payload.URL) assert.Empty(t, payload.SHA256) assert.NotEmpty(t, payload.InstallScript) assert.NotEmpty(t, payload.UninstallScript) assert.True(t, payload.FleetMaintained) payload = fleet.SoftwareInstallerPayload{URL: "https://fleetdm.com"} err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) require.NoError(t, err) assert.Nil(t, payload.Slug) assert.Equal(t, "https://fleetdm.com", payload.URL) assert.Empty(t, payload.SHA256) assert.Empty(t, payload.InstallScript) assert.Empty(t, payload.UninstallScript) assert.False(t, payload.FleetMaintained) } func TestGetInHouseAppManifest(t *testing.T) { ds := new(mock.Store) svc := newTestService(t, ds) ctx := context.Background() ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://example.com"}}, nil } ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { if titleID == 1 { return &fleet.SoftwareInstaller{ BundleIdentifier: "com.foo.bar", Version: "1.2.3", SoftwareTitle: "test in-house app", StorageID: "123storageid", }, nil } return nil, notFoundError{} } expected := ` items assets kind software-package url https://example.com/api/latest/fleet/software/titles/1/in_house_app?team_id=0 kind display-image needs-shine url metadata bundle-identifier com.foo.bar bundle-version 1.2.3 kind software title test in-house app ` manifest, err := svc.GetInHouseAppManifest(ctx, 1, nil) require.NoError(t, err) assert.Equal(t, expected, string(manifest)) _, err = svc.GetInHouseAppManifest(ctx, 2, nil) assert.Error(t, err) assert.True(t, fleet.IsNotFound(err)) // Set up a new S3 store to test CloudFront signing signer, _ := rsa.GenerateKey(rand.Reader, 2048) svc.config.S3.SoftwareInstallersCloudFrontSigner = signer signerURL := "https://example.cloudfront.net" s3Config := config.S3Config{ SoftwareInstallersCloudFrontURL: signerURL, SoftwareInstallersCloudFrontURLSigningPublicKeyID: "ABC123XYZ", SoftwareInstallersCloudFrontSigner: signer, } s3Store, err := s3.NewTestSoftwareInstallerStore(s3Config) require.NoError(t, err) svc.softwareInstallStore = s3Store manifest, err = svc.GetInHouseAppManifest(ctx, 1, nil) require.NoError(t, err) require.Contains(t, string(manifest), signerURL) } func checkAuthErr(t *testing.T, shouldFail bool, err error) { t.Helper() if shouldFail { require.Error(t, err) var forbiddenError *authz.Forbidden require.ErrorAs(t, err, &forbiddenError) } else { require.NoError(t, err) } } func newTestService(t *testing.T, ds fleet.Datastore) *Service { t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) svc := &Service{ authz: authorizer, ds: ds, } return svc } func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmock.Service) { t.Helper() authorizer, err := authz.NewAuthorizer() require.NoError(t, err) baseSvc := new(svcmock.Service) svc := &Service{ Service: baseSvc, authz: authorizer, ds: ds, } 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, "sh_packages", 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, "ps1_packages", 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) }) } // TestInstallShScriptOnDarwin tests that .sh scripts (stored as platform='linux') // can be installed on darwin hosts. func TestInstallShScriptOnDarwin(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Mock darwin host ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: ptr.String("orbit_key"), Platform: "darwin", TeamID: ptr.Uint(1), }, nil } // Not an in-house app ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } // Mock .sh installer metadata (platform='linux' as .sh files are stored) ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.sh", Extension: "sh", Platform: "linux", // .sh stored as linux TeamID: ptr.Uint(1), TitleID: ptr.Uint(100), SelfService: false, }, nil } // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } // No pending install ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } // Capture that install request was inserted ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { return "install-uuid", nil } // Create admin user context ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, }) // Install .sh on darwin should succeed (not return BadRequestError) err := svc.InstallSoftwareTitle(ctx, 1, 100) require.NoError(t, err, ".sh install on darwin should succeed") require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created") } // TestInstallShScriptOnWindowsFails tests that .sh scripts can't be installed on Windows hosts. func TestInstallShScriptOnWindowsFails(t *testing.T) { t.Parallel() ds := new(mock.Store) svc := newTestService(t, ds) // Mock Windows host ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { return &fleet.Host{ ID: 1, OrbitNodeKey: ptr.String("orbit_key"), Platform: "windows", TeamID: ptr.Uint(1), }, nil } // Not an in-house app ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return nil, nil } // Mock .sh installer metadata ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{ InstallerID: 10, Name: "script.sh", Extension: "sh", Platform: "linux", TeamID: ptr.Uint(1), TitleID: ptr.Uint(100), SelfService: false, }, nil } // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil } // No pending install ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } // Create admin user context ctx := viewer.NewContext(context.Background(), viewer.Viewer{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, }) // Install .sh on windows should fail with BadRequestError err := svc.InstallSoftwareTitle(ctx, 1, 100) require.Error(t, err, ".sh install on windows should fail") var bre *fleet.BadRequestError require.ErrorAs(t, err, &bre, "error should be BadRequestError") require.NotNil(t, bre) require.Contains(t, bre.Message, "can be installed only on linux hosts") }