diff --git a/.github/ISSUE_TEMPLATE/story.md b/.github/ISSUE_TEMPLATE/story.md index 2e592a9f21..54fffcd59b 100644 --- a/.github/ISSUE_TEMPLATE/story.md +++ b/.github/ISSUE_TEMPLATE/story.md @@ -35,6 +35,7 @@ What else should contributors [keep in mind](https://fleetdm.com/handbook/compan - [ ] UI changes: TODO - [ ] CLI usage changes: TODO - [ ] REST API changes: TODO +- [ ] Fleet's agent (fleetd) changes: TODO - [ ] Permissions changes: TODO - [ ] Outdated documentation changes: TODO - [ ] Changes to paid features or tiers: TODO diff --git a/changes/16961-return-api-token-for-api-only-users b/changes/16961-return-api-token-for-api-only-users new file mode 100644 index 0000000000..97c5ce6a01 --- /dev/null +++ b/changes/16961-return-api-token-for-api-only-users @@ -0,0 +1,2 @@ +- Endpoint `/api/latest/fleet/users/admin` to return API token when creating API-only (non-SSO) users. +- Added API-token of the created API-only (non-SSO) user to the output of `fleetctl user create --api-only`. diff --git a/changes/18427-cert-names b/changes/18427-cert-names new file mode 100644 index 0000000000..f5f9bea1d8 --- /dev/null +++ b/changes/18427-cert-names @@ -0,0 +1 @@ +* Use Fleet instead of FleetDM in MDM certificates diff --git a/changes/19512-mdm-migration-sonoma b/changes/19512-mdm-migration-sonoma new file mode 100644 index 0000000000..d82dff2208 --- /dev/null +++ b/changes/19512-mdm-migration-sonoma @@ -0,0 +1 @@ +- Fixed bug where MDM migration failed when attempting to renew enrollment profiles on macOS Sonoma devices. diff --git a/changes/19545-unlock-pin b/changes/19545-unlock-pin new file mode 100644 index 0000000000..ee0f715202 --- /dev/null +++ b/changes/19545-unlock-pin @@ -0,0 +1,2 @@ +* /api/latest/fleet/hosts/:id/lock returns `unlock_pin` for Apple hosts +* UI no longer uses unlock pending state for Apple hosts diff --git a/changes/19600-add-config-to-set-query-report-cap b/changes/19600-add-config-to-set-query-report-cap new file mode 100644 index 0000000000..a016c325ff --- /dev/null +++ b/changes/19600-add-config-to-set-query-report-cap @@ -0,0 +1 @@ +* Added a server setting to configure the query repory cap size, `server_settings.query_report_cap` (default is 1000). diff --git a/changes/part-of-19072-use-reader-db-for-stats b/changes/part-of-19072-use-reader-db-for-stats new file mode 100644 index 0000000000..a4ad45d70c --- /dev/null +++ b/changes/part-of-19072-use-reader-db-for-stats @@ -0,0 +1 @@ +- Improved db usage when sending statistics diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index c9e56d636e..564f5466fe 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -418,6 +418,7 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration") + assert.Equal(t, 2000, savedAppConfig.ServerSettings.QueryReportCap) assert.Len(t, enrolledSecrets, 2) assert.True(t, policyDeleted) assert.Len(t, appliedPolicySpecs, 5) @@ -923,7 +924,6 @@ team_settings: _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--delete-other-teams"}) assert.True(t, ds.ListTeamsFuncInvoked) assert.True(t, ds.DeleteTeamFuncInvoked) - } func TestFullGlobalAndTeamGitOps(t *testing.T) { @@ -1059,7 +1059,6 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } }) } - } func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go index 117a863f7b..b0e74d647c 100644 --- a/cmd/fleetctl/mdm_test.go +++ b/cmd/fleetctl/mdm_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "slices" @@ -361,13 +362,6 @@ func TestMDMLockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, @@ -409,7 +403,6 @@ func TestMDMLockCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -421,7 +414,6 @@ func TestMDMLockCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -446,9 +438,7 @@ func TestMDMLockCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -542,7 +532,6 @@ fleetctl mdm unlock --host=%s {appCfgWinMDM, "valid windows but pending ", []string{"--host", winPending.UUID}, `Can't lock the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending", []string{"--host", macPending.UUID}, `Can't lock the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, @@ -603,13 +592,6 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -650,7 +632,6 @@ func TestMDMUnlockCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -667,7 +648,6 @@ func TestMDMUnlockCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -701,9 +681,7 @@ func TestMDMUnlockCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -800,7 +778,6 @@ fleetctl get host %s {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't unlock the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't unlock the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, ""}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, @@ -856,13 +833,6 @@ func TestMDMWipeCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - macEnrolledUP := &fleet.Host{ - ID: 9, - UUID: "mac-enrolled-up", - Platform: "darwin", - MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, - MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, - } winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -950,7 +920,6 @@ func TestMDMWipeCommand(t *testing.T) { macPending, winPending, winEnrolledUP, - macEnrolledUP, winEnrolledLP, macEnrolledLP, winEnrolledWP, @@ -971,7 +940,6 @@ func TestMDMWipeCommand(t *testing.T) { unlockPending := map[uint]*fleet.Host{ winEnrolledUP.ID: winEnrolledUP, - macEnrolledUP.ID: macEnrolledUP, } lockPending := map[uint]*fleet.Host{ @@ -1010,9 +978,7 @@ func TestMDMWipeCommand(t *testing.T) { if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { - status.UnlockPIN = "1234" - status.UnlockRequestedAt = time.Now() - return &status, nil + return nil, errors.New("apple devices do not have an unlock pending state") } status.UnlockScript = &fleet.HostScriptResult{} @@ -1129,7 +1095,6 @@ func TestMDMWipeCommand(t *testing.T) { {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, - {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 7d0df63743..c6624c7110 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -11,6 +11,7 @@ "server_settings": { "server_url": "", "live_query_disabled": false, + "query_report_cap": 0, "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 01834e56a5..92254b6052 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: false live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: "" scripts_disabled: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 114ba52a9c..18d980b320 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -11,6 +11,7 @@ "server_settings": { "server_url": "", "live_query_disabled": false, + "query_report_cap": 0, "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 203246eb0d..3138a7d349 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -98,6 +98,7 @@ spec: deferred_save_host: false enable_analytics: false live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: "" scripts_disabled: false diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 0a73e7392d..76936e3ad5 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -101,6 +101,7 @@ org_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: $FLEET_SERVER_URL diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 237ea64e3a..67bb96e8c3 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: https://example.org scripts_disabled: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 95b1be28ac..d73894e4a1 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -59,6 +59,7 @@ spec: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 0 query_reports_disabled: false server_url: https://example.org scripts_disabled: false diff --git a/cmd/fleetctl/user.go b/cmd/fleetctl/user.go index 38b5a43edb..ce0a842c7d 100644 --- a/cmd/fleetctl/user.go +++ b/cmd/fleetctl/user.go @@ -160,7 +160,7 @@ func createUserCommand() *cli.Command { force_reset := !sso && !apiOnly // password requirements are validated as part of `CreateUser` - err = client.CreateUser(fleet.UserPayload{ + sessionKey, err := client.CreateUser(fleet.UserPayload{ Password: &password, Email: &email, Name: &name, @@ -174,6 +174,10 @@ func createUserCommand() *cli.Command { return fmt.Errorf("Failed to create user: %w", err) } + if apiOnly && sessionKey != nil && *sessionKey != "" { + fmt.Fprintf(c.App.Writer, "Success! The API token for your new user is: %s\n", *sessionKey) + } + return nil }, } @@ -208,7 +212,6 @@ func createBulkUsersCommand() *cli.Command { } defer csvFile.Close() csvLines, err := csv.NewReader(csvFile).ReadAll() - if err != nil { return err } @@ -278,7 +281,7 @@ func createBulkUsersCommand() *cli.Command { } for _, user := range users { - err = client.CreateUser(user) + _, err = client.CreateUser(user) if err != nil { return fmt.Errorf("Failed to create user: %w", err) } @@ -351,7 +354,6 @@ func deleteBulkUsersCommand() *cli.Command { } defer csvFile.Close() csvLines, err := csv.NewReader(csvFile).ReadAll() - if err != nil { return err } @@ -362,10 +364,10 @@ func deleteBulkUsersCommand() *cli.Command { } } return nil - }, } } + func generateRandomPassword() (string, error) { password, err := password.Generate(20, 2, 2, false, true) if err != nil { diff --git a/cmd/fleetctl/users_test.go b/cmd/fleetctl/users_test.go index e65cc88d3a..c3b43a5820 100644 --- a/cmd/fleetctl/users_test.go +++ b/cmd/fleetctl/users_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/csv" + "fmt" "math/big" "os" "strings" @@ -73,31 +74,57 @@ func TestUserCreateForcePasswordReset(t *testing.T) { ) error { return nil } + ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) { + if email == "bar@example.com" { + apiOnlyUser := &fleet.User{ + ID: 1, + Email: email, + } + err := apiOnlyUser.SetPassword(pwd, 24, 10) + require.NoError(t, err) + return apiOnlyUser, nil + } + return nil, ¬FoundError{} + } + var apiOnlyUserSessionKey string + ds.NewSessionFunc = func(ctx context.Context, userID uint, sessionKey string) (*fleet.Session, error) { + apiOnlyUserSessionKey = sessionKey + return &fleet.Session{ + ID: 2, + UserID: userID, + Key: sessionKey, + }, nil + } for _, tc := range []struct { name string args []string expectedAdminForcePasswordReset bool + displaysToken bool }{ { name: "sso", args: []string{"--email", "foo@example.com", "--name", "foo", "--sso"}, expectedAdminForcePasswordReset: false, + displaysToken: false, }, { name: "api-only", args: []string{"--email", "bar@example.com", "--password", pwd, "--name", "bar", "--api-only"}, expectedAdminForcePasswordReset: false, + displaysToken: true, }, { name: "api-only-sso", args: []string{"--email", "baz@example.com", "--name", "baz", "--api-only", "--sso"}, expectedAdminForcePasswordReset: false, + displaysToken: false, }, { name: "non-sso-non-api-only", args: []string{"--email", "zoo@example.com", "--password", pwd, "--name", "zoo"}, expectedAdminForcePasswordReset: true, + displaysToken: false, }, } { ds.NewUserFuncInvoked = false @@ -106,10 +133,15 @@ func TestUserCreateForcePasswordReset(t *testing.T) { return user, nil } - require.Equal(t, "", runAppForTest(t, append( + stdout := runAppForTest(t, append( []string{"user", "create"}, tc.args..., - ))) + )) + if tc.displaysToken { + require.Equal(t, stdout, fmt.Sprintf("Success! The API token for your new user is: %s\n", apiOnlyUserSessionKey)) + } else { + require.Empty(t, stdout) + } require.True(t, ds.NewUserFuncInvoked) } } diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index d2f9c49971..c4c6c0f180 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -38,22 +38,22 @@ func (svc *Service) OSVersion(ctx context.Context, osID uint, teamID *uint, incl return svc.Service.OSVersion(ctx, osID, teamID, true) } -func (svc *Service) LockHost(ctx context.Context, hostID uint) error { +func (svc *Service) LockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) { // First ensure the user has access to list hosts, then check the specific // host once team_id is loaded. if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { - return err + return "", err } host, err := svc.ds.HostLite(ctx, hostID) if err != nil { - return ctxerr.Wrap(ctx, err, "get host lite") + return "", ctxerr.Wrap(ctx, err, "get host lite") } // Authorize again with team loaded now that we have the host's team_id. // Authorize as "execute mdm_command", which is the correct access // requirement and is what happens for macOS platforms. if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { - return err + return "", err } // locking validations are based on the platform of the host @@ -65,19 +65,23 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { if errors.Is(err, fleet.ErrMDMNotConfigured) { err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) } - return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") + return "", ctxerr.Wrap(ctx, err, "check macOS MDM enabled") } // on macOS, the lock command requires the host to be MDM-enrolled in Fleet hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) if err != nil { if fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on."), + ) } - return ctxerr.Wrap(ctx, err, "get host MDM information") + return "", ctxerr.Wrap(ctx, err, "get host MDM information") } if !hostMDM.IsFleetEnrolled() { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on."), + ) } case "windows", "linux": @@ -86,27 +90,30 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { if errors.Is(err, fleet.ErrMDMNotConfigured) { err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) } - return ctxerr.Wrap(ctx, err, "check windows MDM enabled") + return "", ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } // on windows and linux, a script is used to lock the host so scripts must // be enabled appCfg, err := svc.ds.AppConfig(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "get app config") + return "", ctxerr.Wrap(ctx, err, "get app config") } if appCfg.ServerSettings.ScriptsDisabled { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock host because running scripts is disabled in organization settings.")) + return "", ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("host_id", "Can't lock host because running scripts is disabled in organization settings."), + ) } hostOrbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) switch { case err != nil: // If not found, then do nothing. We do not know if this host has scripts enabled or not if !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "get host orbit info") + return "", ctxerr.Wrap(ctx, err, "get host orbit info") } case hostOrbitInfo.ScriptsEnabled != nil && !*hostOrbitInfo.ScriptsEnabled: - return ctxerr.Wrap( + return "", ctxerr.Wrap( ctx, fleet.NewInvalidArgumentError( "host_id", "Couldn't lock host. To lock, deploy the fleetd agent with --enable-scripts and refetch host vitals.", ), @@ -114,26 +121,37 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { } default: - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } // if there's a lock, unlock or wipe action pending, do not accept the lock // request. lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { - return ctxerr.Wrap(ctx, err, "get host lock/wipe status") + return "", ctxerr.Wrap(ctx, err, "get host lock/wipe status") } switch { case lockWipe.IsPendingLock(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. The host will lock when it comes online.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. The host will lock when it comes online."), + ) case lockWipe.IsPendingUnlock(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError( + "host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.", + ), + ) case lockWipe.IsPendingWipe(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped.")) + return "", ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped."), + ) case lockWipe.IsWiped(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped.")) + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped."), + ) case lockWipe.IsLocked(): - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) } // all good, go ahead with queuing the lock request. @@ -333,19 +351,21 @@ func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { return svc.enqueueWipeHostRequest(ctx, host, lockWipe) } -func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) error { +func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) ( + unlockPIN string, err error, +) { vc, ok := viewer.FromContext(ctx) if !ok { - return fleet.ErrNoContext + return "", fleet.ErrNoContext } if lockStatus.HostFleetPlatform == "darwin" { lockCommandUUID := uuid.NewString() - if err := svc.mdmAppleCommander.DeviceLock(ctx, host, lockCommandUUID); err != nil { - return ctxerr.Wrap(ctx, err, "enqueuing lock request for darwin") + if unlockPIN, err = svc.mdmAppleCommander.DeviceLock(ctx, host, lockCommandUUID); err != nil { + return "", ctxerr.Wrap(ctx, err, "enqueuing lock request for darwin") } - if err := svc.NewActivity( + if err = svc.NewActivity( ctx, vc.User, fleet.ActivityTypeLockedHost{ @@ -353,10 +373,10 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host HostDisplayName: host.DisplayName(), }, ); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for darwin lock host request") + return "", ctxerr.Wrap(ctx, err, "create activity for darwin lock host request") } - return nil + return unlockPIN, nil } script := windowsLockScript @@ -376,7 +396,7 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host UserID: &vc.User.ID, SyncRequest: false, }, host.FleetPlatform()); err != nil { - return err + return "", err } if err := svc.NewActivity( @@ -387,10 +407,10 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host HostDisplayName: host.DisplayName(), }, ); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for lock host request") + return "", ctxerr.Wrap(ctx, err, "create activity for lock host request") } - return nil + return "", nil } func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) (string, error) { @@ -401,7 +421,9 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho var unlockPIN string if lockStatus.HostFleetPlatform == "darwin" { - // record the unlock request if it was not already recorded + // Record the unlock request time if it was not already recorded. + // It should be always recorded, since the UnlockRequestedAt time is created after the lock command is acknowledged. + // This code is left here to catch potential issues. if lockStatus.UnlockRequestedAt.IsZero() { if err := svc.ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()); err != nil { return "", err diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 7af4d4f9b8..890371700f 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -135,7 +135,7 @@ func (svc *Service) MDMAppleDeviceLock(ctx context.Context, hostID uint) error { return err } - err = svc.mdmAppleCommander.DeviceLock(ctx, host, uuid.New().String()) + _, err = svc.mdmAppleCommander.DeviceLock(ctx, host, uuid.New().String()) if err != nil { return err } diff --git a/frontend/__mocks__/queryReportMock.ts b/frontend/__mocks__/queryReportMock.ts index eb538473d9..016812e7d4 100644 --- a/frontend/__mocks__/queryReportMock.ts +++ b/frontend/__mocks__/queryReportMock.ts @@ -320,6 +320,7 @@ const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = { }, }, ], + report_clipped: false, }; const createMockQueryReport = ( diff --git a/frontend/interfaces/query_report.ts b/frontend/interfaces/query_report.ts index 051357827e..97fc32dd90 100644 --- a/frontend/interfaces/query_report.ts +++ b/frontend/interfaces/query_report.ts @@ -9,4 +9,5 @@ export interface IQueryReportResultRow { export interface IQueryReport { query_id: number; results: IQueryReportResultRow[]; + report_clipped: boolean; } diff --git a/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx index 3114d73530..2f40fa82b6 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/AutoEnrollMdmModal/AutoEnrollMdmModal.tsx @@ -2,16 +2,27 @@ import React from "react"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; +import { IDeviceUserResponse } from "interfaces/host"; interface IAutoEnrollMdmModalProps { + host: IDeviceUserResponse["host"]; onCancel: () => void; } const baseClass = "auto-enroll-mdm-modal"; const AutoEnrollMdmModal = ({ + host: { platform, os_version }, onCancel, }: IAutoEnrollMdmModalProps): JSX.Element => { + let isMacOsSonomaOrLater = false; + if (platform === "darwin" && os_version.startsWith("macOS ")) { + const [major] = os_version + .replace("macOS ", "") + .split(".") + .map((s) => parseInt(s, 10)); + isMacOsSonomaOrLater = major >= 14; + } return (
  1. - Open your Mac’s notification center by selecting the date and time - in the top right corner of your screen. + From the Apple menu in the top left corner of your screen, select{" "} + System Settings or System Preferences.
  2. - Select the Device Enrollment notification. This will open{" "} - System Settings or System Preferences. Select{" "} - Allow. + {isMacOsSonomaOrLater ? ( + <> + In the sidebar menu, select Enroll in Remote Management, + and select Enroll. + + ) : ( + <> + In the search bar, type “Profiles.” Select Profiles, find + and select Enrollment Profile, and select Install. + + )}
  3. Enter your password, and select Enroll. diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index d950153f48..16b38e11a8 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -311,7 +311,7 @@ const DeviceUserPage = ({ const renderEnrollMdmModal = () => { return host?.dep_assigned_to_fleet ? ( - + ) : ( = QUERY_REPORT_RESULTS_LIMIT; + const isClipped = queryReport?.report_clipped; const disabledLiveQuery = config?.server_settings.live_query_disabled; const renderHeader = () => { diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx index 05ef2ba604..10cc329d00 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx @@ -11,5 +11,3 @@ export type QueryDetailsPageQueryParams = Record< export const DEFAULT_SORT_HEADER = "host_name"; export const DEFAULT_SORT_DIRECTION = "asc"; - -export const QUERY_REPORT_RESULTS_LIMIT = 1000; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx index 17a04dffda..93492b3556 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tests.tsx @@ -24,6 +24,7 @@ describe("QueryReport", () => { columns: { col1: "value3", col2: "value4" }, }, ], + report_clipped: false, }, ]; render(); @@ -56,6 +57,7 @@ describe("QueryReport", () => { }, }, ], + report_clipped: false, }, ]; render(); @@ -83,6 +85,7 @@ describe("QueryReport", () => { columns: { col1: "value1", col2: "value2" }, }, ], + report_clipped: true, }, ]; render(); diff --git a/orbit/pkg/profiles/profiles_darwin.go b/orbit/pkg/profiles/profiles_darwin.go index b530e15b19..93d197a587 100644 --- a/orbit/pkg/profiles/profiles_darwin.go +++ b/orbit/pkg/profiles/profiles_darwin.go @@ -247,6 +247,6 @@ func parseEnrollmentProfileValue(line []byte, key string) (string, bool) { // showEnrollmentProfileCmd is declared as a variable so it can be overwritten by tests. var showEnrollmentProfileCmd = func() ([]byte, error) { - cmd := exec.Command("/usr/bin/profiles", "show", "-type", "enrollment") + cmd := exec.Command("sh", "-c", `launchctl asuser $(id -u $(stat -f "%u" /dev/console)) profiles show -type enrollment`) return cmd.Output() } diff --git a/orbit/pkg/update/execcmd_darwin.go b/orbit/pkg/update/execcmd_darwin.go index 65cbdf7c27..6eb8847282 100644 --- a/orbit/pkg/update/execcmd_darwin.go +++ b/orbit/pkg/update/execcmd_darwin.go @@ -3,5 +3,6 @@ package update func runRenewEnrollmentProfile() error { - return runCmdCollectErr("/usr/bin/profiles", "renew", "--type", "enrollment") + cmd := `launchctl asuser $(id -u $(stat -f "%u" /dev/console)) profiles renew -type enrollment` + return runCmdCollectErr("sh", "-c", cmd) } diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index c185ee64bd..63224ac859 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -104,14 +104,15 @@ func (h *renewEnrollmentProfileConfigReceiver) Run(config *fleet.OrbitConfig) er fn = runRenewEnrollmentProfile } if err := fn(); err != nil { - // TODO: Look into whether we should increment lastRun here or implement a - // backoff to avoid unnecessary user notification popups and mitigate rate - // limiting by Apple. log.Info().Err(err).Msg("calling /usr/bin/profiles to renew enrollment profile failed") - } else { - h.lastRun = time.Now() - log.Info().Msg("successfully called /usr/bin/profiles to renew enrollment profile") + // TODO: Design a better way to backoff `profiles show` so that the device doesn't get rate + // limited by Apple. For now, wait at least 2 minutes before retrying. + h.lastRun = time.Now().Add(-h.Frequency).Add(2 * time.Minute) + return nil } + h.lastRun = time.Now() + log.Info().Msg("successfully called /usr/bin/profiles to renew enrollment profile") + } else { log.Debug().Msg("skipped calling /usr/bin/profiles to renew enrollment profile, last run was too recent") } diff --git a/orbit/pkg/useraction/mdm_migration_darwin.go b/orbit/pkg/useraction/mdm_migration_darwin.go index a282a9a05c..323fc847fa 100644 --- a/orbit/pkg/useraction/mdm_migration_darwin.go +++ b/orbit/pkg/useraction/mdm_migration_darwin.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "os/exec" + "strconv" + "strings" "sync" "text/template" "time" @@ -48,7 +50,7 @@ const mdmUnenrollmentTotalWaitTime = 90 * time.Second // between unenrollment checks. const defaultUnenrollmentRetryInterval = 5 * time.Second -var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(` +var mdmMigrationTemplatePreSonoma = template.Must(template.New("mdmMigrationTemplate").Parse(` ## Migrate to Fleet Select **Start** and look for this notification in your notification center:` + @@ -56,6 +58,14 @@ Select **Start** and look for this notification in your notification center:` + "After you start, this window will popup every 15-20 minutes until you finish.", )) +var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(` +## Migrate to Fleet + +Select **Start** and Remote Management window will appear soon:` + + "\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-sonoma-1500x938.png)\n\n" + + "After you start, this window will popup every 15-20 minutes until you finish.", +)) + var errorTemplate = template.Must(template.New("").Parse(` ### Something's gone wrong. @@ -291,29 +301,9 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error { } func (m *swiftDialogMDMMigrator) renderMigration() error { - var message bytes.Buffer - if err := mdmMigrationTemplate.Execute( - &message, - m.props, - ); err != nil { - return fmt.Errorf("execute template: %w", err) - } - - flags := []string{ - // main button - "--button1text", "Start", - // secondary button - "--button2text", "Later", - "--height", "440", - } - - if m.props.OrgInfo.ContactURL != "" { - flags = append(flags, - // info button - "--infobuttontext", "Unsure? Contact IT", - "--infobuttonaction", m.props.OrgInfo.ContactURL, - "--quitoninfo", - ) + message, flags, err := m.getMessageAndFlags() + if err != nil { + return fmt.Errorf("getting mdm migrator message: %w", err) } exitCodeCh, errCh := m.render(message.String(), flags...) @@ -419,3 +409,71 @@ func (m *swiftDialogMDMMigrator) ShowInterval() error { func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) { m.props = props } + +func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, error) { + vers, err := m.getMacOSMajorVersion() + if err != nil { + // log error for debugging and continue with default template + log.Error().Err(err).Msg("getting macOS major version failed: using default migration template") + } + + tmpl := mdmMigrationTemplate + height := "669" + if vers != 0 && vers < 14 { + height = "440" + tmpl = mdmMigrationTemplatePreSonoma + } + + var message bytes.Buffer + if err := tmpl.Execute( + &message, + m.props, + ); err != nil { + return nil, nil, fmt.Errorf("executing migrqation template: %w", err) + } + + flags := []string{ + // main button + "--button1text", "Start", + // secondary button + "--button2text", "Later", + "--height", height, + } + + if m.props.OrgInfo.ContactURL != "" { + flags = append(flags, + // info button + "--infobuttontext", "Unsure? Contact IT", + "--infobuttonaction", m.props.OrgInfo.ContactURL, + "--quitoninfo", + ) + } + + return &message, flags, nil +} + +// TODO: make this a variable for testing +func (m *swiftDialogMDMMigrator) getMacOSMajorVersion() (int, error) { + cmd := exec.Command("sw_vers", "-productVersion") + out, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("getting macOS version: %w", err) + } + parts := strings.SplitN(string(out), ".", 2) + switch len(parts) { + case 0: + // this should never happen + return 0, errors.New("getting macOS version: sw_vers command returned no output") + case 1: + // unexpected, so log for debugging + log.Debug().Msgf("parsing macOS version: expected 2 parts, got 1: %s", out) + default: + // ok + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, fmt.Errorf("parsing macOS major version: %w", err) + } + return major, nil +} diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 586915a33d..8d9b00bffc 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -137,6 +137,7 @@ func TestValidGitOpsYaml(t *testing.T) { serverSettings, ok := gitops.OrgSettings["server_settings"] assert.True(t, ok, "server_settings not found") assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"]) + assert.EqualValues(t, 2000, serverSettings.(map[string]interface{})["query_report_cap"]) assert.Contains(t, gitops.OrgSettings, "org_info") orgInfo, ok := gitops.OrgSettings["org_info"].(map[string]interface{}) assert.True(t, ok) diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml index cdc6e78923..7fabc5119a 100644 --- a/pkg/spec/testdata/global_config_no_paths.yml +++ b/pkg/spec/testdata/global_config_no_paths.yml @@ -101,6 +101,7 @@ org_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: https://fleet.example.com diff --git a/pkg/spec/testdata/org-settings.yml b/pkg/spec/testdata/org-settings.yml index 22f17b97a5..fb51b15a2a 100644 --- a/pkg/spec/testdata/org-settings.yml +++ b/pkg/spec/testdata/org-settings.yml @@ -4,6 +4,7 @@ server_settings: deferred_save_host: false enable_analytics: true live_query_disabled: false + query_report_cap: 2000 query_reports_disabled: false scripts_disabled: false server_url: https://fleet.example.com diff --git a/schema/tables/patches.yml b/schema/tables/patches.yml new file mode 100644 index 0000000000..2c8cfc4416 --- /dev/null +++ b/schema/tables/patches.yml @@ -0,0 +1,23 @@ +name: patches +description: |- # (required) string - The description for this table. Note: this field supports Markdown + The `patches` osquery table lists Windows security patch updates. +examples: |- # (optional) string - An example query for this table. Note: This field supports Markdown + Basic query: + + ``` + SELECT * FROM patches; + ``` + + This query determines if a specific hotfix patch is installed: + + ``` + SELECT * FROM patches WHERE hotfix_id='kb5037663'; + ``` +notes: |- # (optional) string - Notes about this table. Note: This field supports Markdown. + Microsoft creates a support page per hotfix patch. Support pages can be discovered by doing a web browser search for the hotfix ID string (e.g., KB5037663). + + Microsoft documentation for [KB5037663](https://support.microsoft.com/en-us/topic/may-29-2024-kb5037853-os-builds-22621-3672-and-22631-3672-preview-dcf14fd8-84d6-4234-9d5b-784c319cd7cf) + + The `patches` table does not include updates that are applied via Windows Installer / Microsoft Standard Installer packages (.msi) or updates downloaded directly from Windows Update (e.g., Service Packs). + + [Windows Installer](https://learn.microsoft.com/en-us/windows/win32/msi/windows-installer-portal) diff --git a/schema/tables/programs.yml b/schema/tables/programs.yml index a3a3e2bccd..4bcd5aa70a 100644 --- a/schema/tables/programs.yml +++ b/schema/tables/programs.yml @@ -24,6 +24,7 @@ notes: |- # (optional) string - Notes about this table. Note: This field support - [Windows Installer](https://learn.microsoft.com/en-us/windows/win32/msi/windows-installer-portal) - [Chocolatey](https://chocolatey.org/) + - The Fleet `chocolatey_packages`[table](https://fleetdm.com/tables/chocolatey_packages) - [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/) - [winget.run](https://winget.run/) - Windows [cmd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd) diff --git a/schema/tables/safari_extensions.yml b/schema/tables/safari_extensions.yml index 68ab38ddd7..ea0d561b39 100644 --- a/schema/tables/safari_extensions.yml +++ b/schema/tables/safari_extensions.yml @@ -1,11 +1,25 @@ name: safari_extensions -description: Installed Safari browser extensions (plugins). +description: Safari extensions add functionality to Safari.app, the native web browser in macOS. The `safari_extensions` table collects all Safari extensions installed on a Mac. columns: - name: uid examples: |- + Collect Safari extensions for all Mac users: + ``` SELECT * FROM users CROSS JOIN safari_extensions USING (uid); ``` notes: |- - - Querying this table requires joining against the `users` table. [Learn more](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) - - Includes installed extensions for all system users. + Because Safari data is intentionally isolated for each macOS user to maintain privacy, this query requires a `JOIN` operation. + + Query explanation: + + - The `safari_extensions` table has a row for each installed extension + - Each row has a column with the `uid` of the user who installed the extension + - Each `uid` from the `safari_extensions` table is matched in the `users` table to collect Safari extensions in the output data for all user accounts on the Mac by the `JOIN` + + Links: + + - Apple dcoumentaion on Safari Extensions: https://support.apple.com/en-us/102343 + - CROSS JOIN SQLite tutorial: https://www.sqlitetutorial.net/sqlite-cross-join/ + - [Fleet documentation on joining against the `users` table](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) + - Fleet users table: https://fleetdm.com/tables/users diff --git a/schema/tables/scheduled_tasks.yml b/schema/tables/scheduled_tasks.yml new file mode 100644 index 0000000000..c20e612c09 --- /dev/null +++ b/schema/tables/scheduled_tasks.yml @@ -0,0 +1,13 @@ +name: scheduled_tasks +description: |- # (required) string - The description for this table. Note: this field supports Markdown + The Windows Task Scheduler tracks and performs automated tasks on a Windows device. The `scheduled_tasks` table collects the data from the Windows Task Scheduler. +examples: |- # (optional) string - An example query for this table. Note: This field supports Markdown + This query collects all tasks that are enabled but have not run: + + ``` + SELECT * FROM scheduled_tasks WHERE enabled='1' AND last_run_message='The task has not yet run.'; + ``` +notes: |- # (optional) string - Notes about this table. Note: This field supports Markdown. + Many automated tasks are added to the Task Scheduler by Windows itself, however, administrators can also customize the Task Scheduler. Scheduled tasks are analogous to Launch Daemons and Launch Agents used on Linux or macOS. Because automation is a potential vector for malicious activity, monitoring the Windows Task Scheduler may be critical in an enterprise environment. + + [Windows Task Scheduler](https://learn.microsoft.com/en-us/windows/win32/taskschd/about-the-task-scheduler) diff --git a/schema/tables/software_update.yml b/schema/tables/software_update.yml index 9ac1ab9025..b853acbd27 100644 --- a/schema/tables/software_update.yml +++ b/schema/tables/software_update.yml @@ -1,12 +1,29 @@ name: software_update +description: The `software_update` table displays the number of updates available from Apple's Software Update service on a Mac. platforms: - darwin -description: Information about available Apple software updates. +examples: |- + Basic query: + + ``` + SELECT * FROM software_update; + ``` columns: - name: software_update_required type: integer required: false description: |- - If true, means one of the Apple softwares installed on this machine has a new available upgrade. -notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + A value of 0 means no updates are available. Any other integer represents the number of updates available. +notes: |- + This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + + Available updates on a Mac can be displayed in the macOS Graphical User Interface (GUI) by clicking on the Apple menu and then selecting “System Settings”. In the System Settings.app, click General > Software Update. + + Apple Software Updates can also be listed in Terminal with the following command: + + ``` + softwareupdate --list --verbose + ``` + + [Update Your Apple Software](https://support.apple.com/guide/personal-safety/update-your-apple-software-ips4930e3486/web) evented: false diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 4d2baf7dba..5240581e9b 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -4603,14 +4603,14 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) - // request an unlock, to make it pending unlock + // request an unlock. This is a NOOP for Apple MDM. err = ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()) require.NoError(t, err) - // it is now locked pending unlock + // it is still locked status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkLockWipeState(t, status, false, true, false, true, false, false) + checkLockWipeState(t, status, false, true, false, false, false, false) // execute CleanMacOSMDMLock to simulate successful unlock err = ds.CleanMacOSMDMLock(ctx, host.UUID) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 91928dd5f5..aa3fd09ed0 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -4255,7 +4255,7 @@ func testHostsIncludesScheduledQueriesInPackStats(t *testing.T, ds *Datastore) { Data: ptr.RawMessage(json.RawMessage(`{"foo": "baz"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) hostResult, err = ds.Host(context.Background(), host.ID) @@ -9067,7 +9067,7 @@ func testHostsAddToTeamCleansUpTeamQueryResults(t *testing.T, ds *Datastore) { h4Global0Results, h4Query1Results, } { - err = ds.OverwriteQueryResultRows(ctx, results) + err = ds.OverwriteQueryResultRows(ctx, results, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) } diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index ce8e89602f..6cc7904198 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -6122,7 +6122,7 @@ func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) { cert := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ - CommonName: "FleetDM Identity", + CommonName: "Fleet Identity", }, NotAfter: notAfter, // use a random value, just to make sure they're diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index f8035b1bd2..eaf8a4b4ff 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -359,7 +359,8 @@ ON DUPLICATE KEY UPDATE // if we received a Wipe command result, update the host's status if wipeCmdUUID != "" { if err := updateHostLockWipeStatusFromResultAndHostUUID(ctx, tx, enrollment.HostUUID, - "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2")); err != nil { + "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2"), false, + ); err != nil { return ctxerr.Wrap(ctx, err, "updating wipe command result in host_mdm_actions") } } diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index fe0aac913b..02e222399e 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -13,7 +13,7 @@ import ( // OverwriteQueryResultRows overwrites the query result rows for a given query and host // in a single transaction, ensuring that the number of rows for the given query // does not exceed the maximum allowed -func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) (err error) { +func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) (err error) { if len(rows) == 0 { return nil } @@ -31,7 +31,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet return ctxerr.Wrap(ctx, err, "counting existing query results") } - if countExisting >= fleet.MaxQueryReportRows { + if countExisting >= maxQueryReportRows { // do not delete any rows if we are already at the limit return nil } @@ -53,7 +53,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // Calculate how many new rows can be added given the maximum limit netRowsAfterDeletion := countExisting - int(countDeleted) - allowedNewRows := fleet.MaxQueryReportRows - netRowsAfterDeletion + allowedNewRows := maxQueryReportRows - netRowsAfterDeletion if allowedNewRows == 0 { return nil } diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index 284ecc6143..ce1f3c8a4c 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -62,7 +62,7 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), query1Rows) + err := ds.OverwriteQueryResultRows(context.Background(), query1Rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert Result Row for different Scheduled Query @@ -76,7 +76,7 @@ func testGetQueryResultRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), query2Rows) + err = ds.OverwriteQueryResultRows(context.Background(), query2Rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) results, err := ds.QueryResultRows(context.Background(), query.ID, fleet.TeamFilter{User: test.UserAdmin}) @@ -125,7 +125,7 @@ func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert 1 Result Row for Query1 Host2 @@ -137,7 +137,7 @@ func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that Query1 returns 2 results for Host1 @@ -215,7 +215,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), globalRow) + err = ds.OverwriteQueryResultRows(context.Background(), globalRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) teamRow := []*fleet.ScheduledQueryResultRow{ @@ -229,7 +229,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), teamRow) + err = ds.OverwriteQueryResultRows(context.Background(), teamRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) observerTeamRow := []*fleet.ScheduledQueryResultRow{ @@ -243,7 +243,7 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow) + err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) filter := fleet.TeamFilter{ @@ -286,7 +286,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert Nil Result Row for Query1, nil data rows are not counted @@ -298,7 +298,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { Data: nil, }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Insert 5 Result Rows for Query2 @@ -317,7 +317,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { resultRows = append(resultRows, resultRow2) } - err = ds.OverwriteQueryResultRows(context.Background(), resultRows) + err = ds.OverwriteQueryResultRows(context.Background(), resultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that ResultCountForQuery returns 1 @@ -366,7 +366,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host1Query2 := []*fleet.ScheduledQueryResultRow{ @@ -380,7 +380,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host1Query2) + err = ds.OverwriteQueryResultRows(context.Background(), host1Query2, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host2ResultRow := []*fleet.ScheduledQueryResultRow{ @@ -394,7 +394,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { }`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host3ResultRow := []*fleet.ScheduledQueryResultRow{ @@ -405,7 +405,7 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { Data: nil, }, } - err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow) + err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that Query1 returns 2 @@ -451,7 +451,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { }, } - err := ds.OverwriteQueryResultRows(context.Background(), initialRow) + err := ds.OverwriteQueryResultRows(context.Background(), initialRow, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Overwrite Result Rows with new data @@ -465,7 +465,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that we get the overwritten data (1 result with USB Mouse data) @@ -486,7 +486,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that the data has not changed @@ -511,7 +511,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { mockTime := time.Now().UTC().Truncate(time.Second) // Generate max rows -1 - maxRows := fleet.MaxQueryReportRows - 1 + maxRows := fleet.DefaultMaxQueryReportRows - 1 maxMinusOneRows := make([]*fleet.ScheduledQueryResultRow, maxRows) for i := 0; i < maxRows; i++ { maxMinusOneRows[i] = &fleet.ScheduledQueryResultRow{ @@ -521,7 +521,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } - err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows) + err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Add an empty data rows which do not count towards the max @@ -532,7 +532,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: nil, }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Confirm that we can still add a row @@ -543,13 +543,13 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that we now have max rows count, err := ds.ResultCountForQuery(context.Background(), query.ID) require.NoError(t, err) - require.Equal(t, fleet.MaxQueryReportRows, count) + require.Equal(t, fleet.DefaultMaxQueryReportRows, count) // Attempt to add another row err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ @@ -559,7 +559,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { LastFetched: mockTime, Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, - }) + }, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Assert that the last row was not added @@ -568,7 +568,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { require.Len(t, host4result, 0) // Generate more than max rows in Query 2 - rows := fleet.MaxQueryReportRows + 50 + rows := fleet.DefaultMaxQueryReportRows + 50 largeBatchRows := make([]*fleet.ScheduledQueryResultRow, rows) for i := 0; i < rows; i++ { largeBatchRows[i] = &fleet.ScheduledQueryResultRow{ @@ -578,13 +578,13 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } - err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) + err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Confirm only max rows are stored for the queryID allResults, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) - require.Len(t, allResults, fleet.MaxQueryReportRows) + require.Len(t, allResults, fleet.DefaultMaxQueryReportRows) // Confirm that new rows are not added when the max is reached newMockTime := mockTime.Add(2 * time.Minute) @@ -597,7 +597,7 @@ func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) host2Results, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host2.ID) @@ -619,7 +619,7 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } - err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) filter := fleet.TeamFilter{User: user, IncludeObserver: true} @@ -655,7 +655,7 @@ func testCleanupQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "Keyboard", "vendor": "Microsoft"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), rows) + err = ds.OverwriteQueryResultRows(context.Background(), rows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Call OverwriteQueryResultRows again with different rows @@ -673,7 +673,7 @@ func testCleanupQueryResultRows(t *testing.T, ds *Datastore) { Data: ptr.RawMessage([]byte(`{"model": "Speakers", "vendor": "Bose"}`)), }, } - err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) + err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) // Cleanup query result rows diff --git a/server/datastore/mysql/scep_test.go b/server/datastore/mysql/scep_test.go index 32347df118..d420d2af18 100644 --- a/server/datastore/mysql/scep_test.go +++ b/server/datastore/mysql/scep_test.go @@ -39,7 +39,7 @@ func TestAppleMDMSCEPSerial(t *testing.T) { func TestAppleMDMPutAndHasCN(t *testing.T) { depot := setup(t) - name := "FleetDM Identity" + name := "Fleet Identity" serial, err := depot.Serial() require.NoError(t, err) cert := x509.Certificate{ diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5abe3d4d53..4b895e53e1 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `calendar_events` ( diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index b89155a4f8..67c223f01f 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -1023,7 +1023,7 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFl return ctxerr.Wrap(ctx, err, "record manual unlock host request") } -func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string) string { +func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string, setUnlockRef bool) string { var alias string stmt := `UPDATE host_mdm_actions ` @@ -1039,7 +1039,14 @@ func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart s // Note that this must not clear the unlock_pin, because recording the // lock request does generate the PIN and store it there to be used by an // eventual unlock. - stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) + if !setUnlockRef { + stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) + } else { + // Currently only used for Apple MDM devices. + // We set the unlock_ref to current time since the device can be unlocked any time after the lock. + // Apple MDM does not have a concept of unlock pending. + stmt += fmt.Sprintf("%sunlock_ref = '%s', %[1]swipe_ref = NULL", alias, time.Now().Format(time.DateTime)) + } case "unlock_ref": // a successful unlock clears itself as well as the lock ref, because // unlock is the default state so we don't need to keep its unlock_ref @@ -1061,26 +1068,30 @@ func (ds *Datastore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Cont // a bit of MDM protocol leaking in the mysql layer, but it's either that or // the other way around (MDM protocol would translate to database column) var refCol string + var setUnlockRef bool switch requestType { case "EraseDevice": refCol = "wipe_ref" case "DeviceLock": refCol = "lock_ref" + setUnlockRef = true default: return nil } - return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded) + return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded, setUnlockRef) } -func updateHostLockWipeStatusFromResultAndHostUUID(ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool) error { - stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`) +func updateHostLockWipeStatusFromResultAndHostUUID( + ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool, setUnlockRef bool, +) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`, setUnlockRef) stmt += ` WHERE h.uuid = ? AND hma.` + refCol + ` = ?` _, err := tx.ExecContext(ctx, stmt, hostUUID, cmdUUID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result via host uuid") } func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { - stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "") + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "", false) stmt += ` WHERE host_id = ?` _, err := tx.ExecContext(ctx, stmt, hostID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result") diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 09736a0dd7..0dbb22e1f4 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -764,6 +765,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.LockScript) + assert.Nil(t, status.UnlockScript) s := status.LockScript require.Equal(t, script, s.ScriptContents) diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 4dc5e5e0d4..11236d9adf 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -24,47 +24,47 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du lic, _ := license.FromContext(ctx) computeStats := func(stats *fleet.StatisticsPayload, since time.Time) error { - enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.writer(ctx)) + enrolledHostsByOS, amountEnrolledHosts, err := amountEnrolledHostsByOSDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount enrolled hosts by os") } - amountUsers, err := tableRowsCount(ctx, ds.writer(ctx), "users") + amountUsers, err := tableRowsCount(ctx, ds.reader(ctx), "users") if err != nil { return ctxerr.Wrap(ctx, err, "amount users") } - amountSoftwaresVersions, err := tableRowsCount(ctx, ds.writer(ctx), "software") + amountSoftwaresVersions, err := tableRowsCount(ctx, ds.reader(ctx), "software") if err != nil { return ctxerr.Wrap(ctx, err, "amount software") } - amountHostSoftwares, err := tableRowsCount(ctx, ds.writer(ctx), "host_software") + amountHostSoftwares, err := tableRowsCount(ctx, ds.reader(ctx), "host_software") if err != nil { return ctxerr.Wrap(ctx, err, "amount host_software") } - amountSoftwareTitles, err := tableRowsCount(ctx, ds.writer(ctx), "software_titles") + amountSoftwareTitles, err := tableRowsCount(ctx, ds.reader(ctx), "software_titles") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_titles") } - amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.writer(ctx), "host_software_installed_paths") + amountHostSoftwareInstalledPaths, err := tableRowsCount(ctx, ds.reader(ctx), "host_software_installed_paths") if err != nil { return ctxerr.Wrap(ctx, err, "amount host_software_installed_paths") } - amountSoftwareCpes, err := tableRowsCount(ctx, ds.writer(ctx), "software_cpe") + amountSoftwareCpes, err := tableRowsCount(ctx, ds.reader(ctx), "software_cpe") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_cpe") } - amountSoftwareCves, err := tableRowsCount(ctx, ds.writer(ctx), "software_cve") + amountSoftwareCves, err := tableRowsCount(ctx, ds.reader(ctx), "software_cve") if err != nil { return ctxerr.Wrap(ctx, err, "amount software_cve") } - amountTeams, err := amountTeamsDB(ctx, ds.writer(ctx)) + amountTeams, err := amountTeamsDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount teams") } - amountPolicies, err := amountPoliciesDB(ctx, ds.writer(ctx)) + amountPolicies, err := amountPoliciesDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount policies") } - amountLabels, err := amountLabelsDB(ctx, ds.writer(ctx)) + amountLabels, err := amountLabelsDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount labels") } @@ -72,11 +72,11 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "statistics app config") } - amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.writer(ctx), since) + amountWeeklyUsers, err := amountActiveUsersSinceDB(ctx, ds.reader(ctx), since) if err != nil { return ctxerr.Wrap(ctx, err, "amount active users") } - amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.writer(ctx)) + amountPolicyViolationDaysActual, amountPolicyViolationDaysPossible, err := amountPolicyViolationDaysDB(ctx, ds.reader(ctx)) if err == sql.ErrNoRows { level.Debug(ds.logger).Log("msg", "amount policy violation days", "err", err) //nolint:errcheck } else if err != nil { @@ -86,15 +86,15 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "statistics error store") } - amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.writer(ctx), ds.logger, config) + amountHostsNotResponding, err := countHostsNotRespondingDB(ctx, ds.reader(ctx), ds.logger, config) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts not responding") } - amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.writer(ctx)) + amountHostsByOrbitVersion, err := amountHostsByOrbitVersionDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts by orbit version") } - amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.writer(ctx)) + amountHostsByOsqueryVersion, err := amountHostsByOsqueryVersionDB(ctx, ds.reader(ctx)) if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts by osquery version") } @@ -134,7 +134,7 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du } dest := statistics{} - err := sqlx.GetContext(ctx, ds.writer(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, `SELECT created_at, updated_at, anonymous_identifier FROM statistics LIMIT 1`) if err != nil { if err == sql.ErrNoRows { anonIdentifier, err := server.GenerateRandomText(64) diff --git a/server/fleet/app.go b/server/fleet/app.go index 4c10781c60..0a04ae8886 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -888,6 +888,16 @@ type ServerSettings struct { QueryReportsDisabled bool `json:"query_reports_disabled"` ScriptsDisabled bool `json:"scripts_disabled"` AIFeaturesDisabled bool `json:"ai_features_disabled"` + QueryReportCap int `json:"query_report_cap"` +} + +const DefaultMaxQueryReportRows int = 1000 + +func (f *ServerSettings) GetQueryReportCap() int { + if f.QueryReportCap <= 0 { + return DefaultMaxQueryReportRows + } + return f.QueryReportCap } // HostExpirySettings contains settings pertaining to automatic host expiry. diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 5691650265..96b0e89a18 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -18,7 +18,7 @@ import ( type MDMAppleCommandIssuer interface { InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error - DeviceLock(ctx context.Context, host *Host, uuid string) error + DeviceLock(ctx context.Context, host *Host, uuid string) (unlockPIN string, err error) EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 89d129a439..f2599a4e52 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -457,7 +457,7 @@ type Datastore interface { QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*ScheduledQueryResultRow, error) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) - OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow) error + OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow, maxQueryReportRows int) error // CleanupDiscardedQueryResults deletes all query results for queries with DiscardData enabled. // Used in cleanups_then_aggregation cron to cleanup rows that were inserted immediately // after DiscardData was set to true due to query caching. diff --git a/server/fleet/osquery.go b/server/fleet/osquery.go index 23722ccddb..9e11d2bc15 100644 --- a/server/fleet/osquery.go +++ b/server/fleet/osquery.go @@ -18,8 +18,7 @@ type Stats struct { const ( // StatusOK is the success code returned by osquery - StatusOK OsqueryStatus = 0 - MaxQueryReportRows int = 1000 + StatusOK OsqueryStatus = 0 ) // QueryContent is the format of a query stanza in an osquery configuration. diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index eb2d1e52d5..bd86ce7532 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -414,8 +414,8 @@ func (s *HostLockWipeStatus) IsPendingLock() bool { func (s HostLockWipeStatus) IsPendingUnlock() bool { if s.HostFleetPlatform == "darwin" || s.HostFleetPlatform == "ios" || s.HostFleetPlatform == "ipados" { - // pending unlock if an unlock was requested - return !s.UnlockRequestedAt.IsZero() + // Apple MDM does not have a concept of pending unlock. + return false } // pending unlock if script execution request is queued but no result yet return s.UnlockScript != nil && s.UnlockScript.ExitCode == nil diff --git a/server/fleet/service.go b/server/fleet/service.go index 732202b139..b493b1bff0 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -106,7 +106,8 @@ type Service interface { CreateUserFromInvite(ctx context.Context, p UserPayload) (user *User, err error) // CreateUser allows an admin to create a new user without first creating and validating invite tokens. - CreateUser(ctx context.Context, p UserPayload) (user *User, err error) + // The sessionKey is only returned (not-nil) when creating API-only (non-SSO) users. + CreateUser(ctx context.Context, p UserPayload) (user *User, sessionKey *string, err error) // CreateInitialUser creates the first user, skipping authorization checks. If a user already exists this method // should fail. @@ -274,12 +275,13 @@ type Service interface { // included in the results. ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*Query, error) GetQuery(ctx context.Context, id uint) (*Query, error) - // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to - GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, error) + // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to. + // Returns a boolean indicating whether the report is clipped. + GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, bool, error) // GetHostQueryReportResults returns all stored results of a query for a specific host GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error) // QueryReportIsClipped returns true if the number of query report rows exceeds the maximum - QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) + QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) NewQuery(ctx context.Context, p QueryPayload) (*Query, error) ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error) DeleteQuery(ctx context.Context, teamID *uint, name string) error @@ -1040,7 +1042,7 @@ type Service interface { BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []ScriptPayload, dryRun bool) error // Script-based methods (at least for some platforms, MDM-based for others) - LockHost(ctx context.Context, hostID uint) error + LockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) WipeHost(ctx context.Context, hostID uint) error diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 0e9f79982d..69280f82f4 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -91,7 +91,7 @@ type DEPService struct { // getDefaultProfile returns a godep.Profile with default values set. func (d *DEPService) getDefaultProfile() *godep.Profile { return &godep.Profile{ - ProfileName: "FleetDM default enrollment profile", + ProfileName: "Fleet default enrollment profile", AllowPairing: true, AutoAdvanceSetup: false, IsSupervised: false, @@ -688,8 +688,8 @@ var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Parse {{ .SCEPURL }} Subject - OFleetDM - CNFleetDM Identity + OFleet + CNFleet Identity PayloadIdentifier diff --git a/server/mdm/apple/cert.go b/server/mdm/apple/cert.go index ec47d0d438..a0393122d2 100644 --- a/server/mdm/apple/cert.go +++ b/server/mdm/apple/cert.go @@ -20,7 +20,7 @@ import ( const ( defaultFleetDMAPIURL = "https://fleetdm.com" getSignedAPNSCSRPath = "/api/v1/deliver-apple-csr" - depCertificateCommonName = "FleetDM" + depCertificateCommonName = "Fleet" depCertificateExpiryDays = 30 ) @@ -208,7 +208,7 @@ func NewSCEPCACertKey() (*x509.Certificate, *rsa.PrivateKey, error) { caCert := depot.NewCACert( depot.WithYears(10), - depot.WithCommonName("FleetDM"), + depot.WithCommonName("Fleet"), ) crtBytes, err := caCert.SelfSign(rand.Reader, key.Public(), key) diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 0afb0666b2..fbacedfe4a 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -90,8 +90,8 @@ func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []str return ctxerr.Wrap(ctx, err, "commander remove profile") } -func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, uuid string) error { - pin := GenerateRandomPin(6) +func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, uuid string) (unlockPIN string, err error) { + unlockPIN = GenerateRandomPin(6) raw := fmt.Sprintf(` @@ -106,22 +106,23 @@ func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, %s -`, uuid, pin) +`, uuid, unlockPIN, + ) cmd, err := mdm.DecodeCommand([]byte(raw)) if err != nil { - return ctxerr.Wrap(ctx, err, "decoding command") + return "", ctxerr.Wrap(ctx, err, "decoding command") } - if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, pin); err != nil { - return ctxerr.Wrap(ctx, err, "enqueuing for DeviceLock") + if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, unlockPIN); err != nil { + return "", ctxerr.Wrap(ctx, err, "enqueuing for DeviceLock") } if err := svc.sendNotifications(ctx, []string{host.UUID}); err != nil { - return ctxerr.Wrap(ctx, err, "sending notifications for DeviceLock") + return "", ctxerr.Wrap(ctx, err, "sending notifications for DeviceLock") } - return nil + return unlockPIN, nil } func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 361aece383..5978944b52 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -132,8 +132,9 @@ func TestMDMAppleCommander(t *testing.T) { require.Len(t, pin, 6) return nil } - err = cmdr.DeviceLock(ctx, host, cmdUUID) + pin, err := cmdr.DeviceLock(ctx, host, cmdUUID) require.NoError(t, err) + require.Len(t, pin, 6) require.True(t, mdmStorage.EnqueueDeviceLockCommandFuncInvoked) mdmStorage.EnqueueDeviceLockCommandFuncInvoked = false require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 32b3a98910..9113d897b7 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -339,7 +339,7 @@ type ResultCountForQueryFunc func(ctx context.Context, queryID uint) (int, error type ResultCountForQueryAndHostFunc func(ctx context.Context, queryID uint, hostID uint) (int, error) -type OverwriteQueryResultRowsFunc func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error +type OverwriteQueryResultRowsFunc func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error type CleanupDiscardedQueryResultsFunc func(ctx context.Context) error @@ -3508,11 +3508,11 @@ func (s *DataStore) ResultCountForQueryAndHost(ctx context.Context, queryID uint return s.ResultCountForQueryAndHostFunc(ctx, queryID, hostID) } -func (s *DataStore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { +func (s *DataStore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { s.mu.Lock() s.OverwriteQueryResultRowsFuncInvoked = true s.mu.Unlock() - return s.OverwriteQueryResultRowsFunc(ctx, rows) + return s.OverwriteQueryResultRowsFunc(ctx, rows, maxQueryReportRows) } func (s *DataStore) CleanupDiscardedQueryResults(ctx context.Context) error { diff --git a/server/service/client_users.go b/server/service/client_users.go index aedf807f77..6845997515 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -8,11 +8,16 @@ import ( ) // CreateUser creates a new user, skipping the invitation process. -func (c *Client) CreateUser(p fleet.UserPayload) error { +// +// The session key (aka API token) is returned only when creating +// API only users. +func (c *Client) CreateUser(p fleet.UserPayload) (*string, error) { verb, path := "POST", "/api/latest/fleet/users/admin" var responseBody createUserResponse - - return c.authenticatedRequest(p, verb, path, &responseBody) + if err := c.authenticatedRequest(p, verb, path, &responseBody); err != nil { + return nil, err + } + return responseBody.Token, nil } // ListUsers retrieves the list of users. diff --git a/server/service/hosts.go b/server/service/hosts.go index 2841ba8b80..d9c6ab9edb 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1231,7 +1231,12 @@ func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fl return getHostQueryReportResponse{Err: err}, nil } - isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID) + appConfig, err := svc.AppConfigObfuscated(ctx) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID, appConfig.ServerSettings.GetQueryReportCap()) if err != nil { return getHostQueryReportResponse{Err: err}, nil } diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index bc07f5d7d2..65dec1c23f 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -1648,9 +1648,9 @@ func TestLockUnlockWipeHostAuth(t *testing.T) { } ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - err := svc.LockHost(ctx, globalHostID) + _, err := svc.LockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - err = svc.LockHost(ctx, teamHostID) + _, err = svc.LockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Pretend we locked the host diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a0c0fa916b..e946968424 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -281,6 +281,44 @@ func (s *integrationTestSuite) TestQueryCreationLogsActivity() { require.True(t, found) } +func (s *integrationTestSuite) TestCreatingAPIOnlyUserReturnsAPIToken() { + t := s.T() + + defer func() { + s.token = s.getTestAdminToken() + }() + + var createResp createUserResponse + params := fleet.UserPayload{ + Name: ptr.String("someadmin"), + Email: ptr.String("someadmin@example.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleAdmin), + APIOnly: ptr.Bool(false), + } + s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp) + assert.NotZero(t, createResp.User.ID) + assert.Nil(t, createResp.Token) + + params = fleet.UserPayload{ + Name: ptr.String("apionly"), + Email: ptr.String("apionly@example.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: ptr.Bool(true), + // AdminForcedPasswordReset is set to false when creating api-only users via `fleetctl user create --api-only`. + AdminForcedPasswordReset: ptr.Bool(false), + } + s.DoJSON("POST", "/api/latest/fleet/users/admin", params, http.StatusOK, &createResp) + assert.NotZero(t, createResp.User.ID) + assert.NotNil(t, createResp.Token) + + s.token = *createResp.Token + var chr countHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/count", countHostsRequest{}, http.StatusOK, &chr) + assert.Equal(t, 0, chr.Count) +} + func (s *integrationTestSuite) TestActivityUserEmailPersistsAfterDeletion() { t := s.T() @@ -10799,12 +10837,14 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Re-add results to our query and check that they're actually there s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) // don't change platform or min_osquery_version and results should not be deleted s.DoJSON("POST", "/api/latest/fleet/spec/queries", applyQuerySpecsRequest{ @@ -10812,6 +10852,7 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) // now update the platform and results should be deleted. osqueryInfoQuerySpec.Platform = "darwin" @@ -10820,30 +10861,35 @@ func (s *integrationTestSuite) TestQueryReports() { }, http.StatusOK, &applyResp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Update logging type, which should cause results deletion s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", usbDevicesQuery.ID), modifyQueryRequest{ID: usbDevicesQuery.ID, QueryPayload: fleet.QueryPayload{Logging: &fleet.LoggingDifferential}}, http.StatusOK, &modifyQueryResp) require.Equal(t, fleet.LoggingDifferential, modifyQueryResp.Query.Logging) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", usbDevicesQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Re-add results to our query and check that they're actually there s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 1) + require.False(t, gqrr.ReportClipped) discardData := true s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", osqueryInfoQuery.ID), modifyQueryRequest{ID: osqueryInfoQuery.ID, QueryPayload: fleet.QueryPayload{DiscardData: &discardData}}, http.StatusOK, &modifyQueryResp) require.True(t, modifyQueryResp.Query.DiscardData) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // check that now that discardData is set, we don't add new results s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.Len(t, gqrr.Results, 0) + require.False(t, gqrr.ReportClipped) // Verify that we can't have more than 1k results @@ -10855,7 +10901,7 @@ func (s *integrationTestSuite) TestQueryReports() { NodeKey: *host1Global.NodeKey, LogType: "result", Data: json.RawMessage(`[{ - "snapshot": [` + results(1000, host1Global.UUID) + ` + "snapshot": [` + results(fleet.DefaultMaxQueryReportRows, host1Global.UUID) + ` ], "action": "snapshot", "name": "pack/Global/` + osqueryInfoQuery.Name + `", @@ -10878,13 +10924,14 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.True(t, gqrr.ReportClipped) ghqrr = getHostQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) require.NoError(t, ghqrr.Err) + require.Len(t, ghqrr.Results, fleet.DefaultMaxQueryReportRows) require.True(t, ghqrr.ReportClipped) - require.Len(t, ghqrr.Results, fleet.MaxQueryReportRows) slreq.Data = json.RawMessage(`[{ "snapshot": [` + results(1, host1Global.UUID) + ` @@ -10906,7 +10953,41 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.True(t, gqrr.ReportClipped) + + appConfigSpec := map[string]map[string]int{ + "server_settings": {"query_report_cap": fleet.DefaultMaxQueryReportRows + 1}, + } + s.Do("PATCH", "/api/latest/fleet/config", appConfigSpec, http.StatusOK) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows) + require.False(t, gqrr.ReportClipped) + + slreq.Data = json.RawMessage(`[{ + "snapshot": [` + results(1002, host1Global.UUID) + ` + ], + "action": "snapshot", + "name": "pack/Global/` + osqueryInfoQuery.Name + `", + "hostIdentifier": "` + *host1Global.OsqueryHostID + `", + "calendarTime": "Fri Oct 6 18:13:04 2023 UTC", + "unixTime": 1696615984, + "epoch": 0, + "counter": 0, + "numerics": false, + "decorations": { + "host_uuid": "187c4d56-8e45-1a9d-8513-ac17efd2f0fd", + "hostname": "` + host1Global.Hostname + `" + } +}]`) + + s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) + require.NoError(t, slres.Err) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) + require.Len(t, gqrr.Results, fleet.DefaultMaxQueryReportRows+1) + require.True(t, gqrr.ReportClipped) // TODO: Set global discard flag and verify that all data is gone. } diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 75fad197f0..72bcb19bf0 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "github.com/stretchr/testify/assert" "net/http" "os" "path/filepath" @@ -73,12 +74,15 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { { "locked host turns on MDM", func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { - s.Do( + var resp lockHostResponse + s.DoJSON( "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, - http.StatusNoContent, + http.StatusOK, + &resp, ) + assert.Len(t, resp.UnlockPIN, 6) cmd, err := device.Idle() require.NoError(t, err) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index e61a5e2ad7..277f22cf55 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -531,9 +531,9 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { var mdmResp getAppleMDMResponse s.DoJSON("GET", "/api/latest/fleet/apns", nil, http.StatusOK, &mdmResp) // returned values are dummy, this is a test certificate - require.Equal(t, "FleetDM", mdmResp.Issuer) + require.Equal(t, "Fleet", mdmResp.Issuer) require.NotZero(t, mdmResp.SerialNumber) - require.Equal(t, "FleetDM", mdmResp.CommonName) + require.Equal(t, "Fleet", mdmResp.CommonName) require.NotZero(t, mdmResp.RenewDate) s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -8038,7 +8038,9 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusConflict, &unlockResp) // lock the host - s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) + var lockResp lockHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusOK, &lockResp) + assert.Len(t, lockResp.UnlockPIN, 6) // refresh the host's status, it is now pending lock s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) @@ -8084,12 +8086,12 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { unlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) - // refresh the host's status, it is locked pending unlock + // refresh the host's status, it is still locked s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) - require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + assert.Empty(t, *getHostResp.Host.MDM.PendingAction) // try unlocking the host again simply returns the PIN again unlockResp = unlockHostResponse{} @@ -8834,7 +8836,7 @@ func (s *integrationMDMTestSuite) appleCoreCertsSetup() { ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, Subject: pkix.Name{ - CommonName: "FleetDM", + CommonName: "Fleet", ExtraNames: []pkix.AttributeTypeAndValue{ { Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}, diff --git a/server/service/osquery.go b/server/service/osquery.go index a806798c03..c4112f7d0d 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1799,7 +1799,8 @@ func (svc *Service) SubmitResultLogs(ctx context.Context, logs []json.RawMessage unmarshaledResults, queriesDBData := svc.preProcessOsqueryResults(ctx, logs, queryReportsDisabled) if !queryReportsDisabled { - svc.saveResultLogsToQueryReports(ctx, unmarshaledResults, queriesDBData) + maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap() + svc.saveResultLogsToQueryReports(ctx, unmarshaledResults, queriesDBData, maxQueryReportRows) } var filteredLogs []json.RawMessage @@ -1861,7 +1862,12 @@ func (svc *Service) SubmitResultLogs(ctx context.Context, logs []json.RawMessage // Query Reports //////////////////////////////////////////////////////////////////////////////// -func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshaledResults []*fleet.ScheduledQueryResult, queriesDBData map[string]*fleet.Query) { +func (svc *Service) saveResultLogsToQueryReports( + ctx context.Context, + unmarshaledResults []*fleet.ScheduledQueryResult, + queriesDBData map[string]*fleet.Query, + maxQueryReportRows int, +) { // skipauth: Authorization is currently for user endpoints only. svc.authz.SkipAuthorization(ctx) @@ -1903,11 +1909,11 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale level.Error(svc.logger).Log("msg", "get result count for query", "err", err, "query_id", dbQuery.ID) continue } - if count >= fleet.MaxQueryReportRows { + if count >= maxQueryReportRows { continue } - if err := svc.overwriteResultRows(ctx, result, dbQuery.ID, host.ID); err != nil { + if err := svc.overwriteResultRows(ctx, result, dbQuery.ID, host.ID, maxQueryReportRows); err != nil { level.Error(svc.logger).Log("msg", "overwrite results", "err", err, "query_id", dbQuery.ID, "host_id", host.ID) continue } @@ -1919,7 +1925,7 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale // The "snapshot" array in a ScheduledQueryResult can contain multiple rows. // Each row is saved as a separate ScheduledQueryResultRow, i.e. a result could contain // many USB Devices or a result could contain all user accounts on a host. -func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.ScheduledQueryResult, queryID, hostID uint) error { +func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.ScheduledQueryResult, queryID, hostID uint, maxQueryReportRows int) error { fetchTime := time.Now() rows := make([]*fleet.ScheduledQueryResultRow, 0, len(result.Snapshot)) @@ -1945,7 +1951,7 @@ func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.Sched rows = append(rows, row) } - if err := svc.ds.OverwriteQueryResultRows(ctx, rows); err != nil { + if err := svc.ds.OverwriteQueryResultRows(ctx, rows, maxQueryReportRows); err != nil { return ctxerr.Wrap(ctx, err, "overwriting query result rows") } return nil diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 878e0ce8e0..eb5eba0aee 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -614,7 +614,7 @@ func TestSubmitResultLogsToLogDestination(t *testing.T) { return 0, nil } teamQueryResultsStored := false - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { if len(rows) == 0 { return nil } @@ -766,7 +766,7 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { Logging: fleet.LoggingSnapshot, }, } - serv.saveResultLogsToQueryReports(ctx, results, discardDataFalse) + serv.saveResultLogsToQueryReports(ctx, results, discardDataFalse, fleet.DefaultMaxQueryReportRows) assert.False(t, ds.OverwriteQueryResultRowsFuncInvoked) // Happy Path: Results saved @@ -777,13 +777,13 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { Logging: fleet.LoggingSnapshot, }, } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { return nil } ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { return 0, nil } - serv.saveResultLogsToQueryReports(ctx, results, discardDataTrue) + serv.saveResultLogsToQueryReports(ctx, results, discardDataTrue, fleet.DefaultMaxQueryReportRows) require.True(t, ds.OverwriteQueryResultRowsFuncInvoked) } @@ -825,7 +825,7 @@ func TestSubmitResultLogsToQueryResultsWithEmptySnapShot(t *testing.T) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) @@ -876,7 +876,7 @@ func TestSubmitResultLogsToQueryResultsDoesNotCountNullDataRows(t *testing.T) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) @@ -933,7 +933,7 @@ func TestSubmitResultLogsFail(t *testing.T) { ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { return 0, nil } - ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow, maxQueryReportRows int) error { return nil } diff --git a/server/service/queries.go b/server/service/queries.go index dcfe1a455e..07b9c43a69 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -121,16 +121,17 @@ type getQueryReportRequest struct { } type getQueryReportResponse struct { - QueryID uint `json:"query_id"` - Results []fleet.HostQueryResultRow `json:"results"` - Err error `json:"error,omitempty"` + QueryID uint `json:"query_id"` + Results []fleet.HostQueryResultRow `json:"results"` + ReportClipped bool `json:"report_clipped"` + Err error `json:"error,omitempty"` } func (r getQueryReportResponse) error() error { return r.Err } func getQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getQueryReportRequest) - queryReportResults, err := svc.GetQueryReportResults(ctx, req.ID) + queryReportResults, reportClipped, err := svc.GetQueryReportResults(ctx, req.ID) if err != nil { return listQueriesResponse{Err: err}, nil } @@ -140,44 +141,53 @@ func getQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet. results = queryReportResults } return getQueryReportResponse{ - QueryID: req.ID, - Results: results, + QueryID: req.ID, + Results: results, + ReportClipped: reportClipped, }, nil } -func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet.HostQueryResultRow, error) { +func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet.HostQueryResultRow, bool, error) { // Load query first to get its teamID. query, err := svc.ds.Query(ctx, id) if err != nil { setAuthCheckedOnPreAuthErr(ctx) - return nil, ctxerr.Wrap(ctx, err, "get query from datastore") + return nil, false, ctxerr.Wrap(ctx, err, "get query from datastore") } if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err } if query.DiscardData { - return nil, nil + return nil, false, nil } vc, ok := viewer.FromContext(ctx) if !ok { - return nil, fleet.ErrNoContext + return nil, false, fleet.ErrNoContext } filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true} queryReportResultRows, err := svc.ds.QueryResultRows(ctx, id, filter) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get query report results") + return nil, false, ctxerr.Wrap(ctx, err, "get query report results") } queryReportResults, err := fleet.MapQueryReportResultsToRows(queryReportResultRows) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "map db rows to results") + return nil, false, ctxerr.Wrap(ctx, err, "map db rows to results") } - return queryReportResults, nil + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "get app config") + } + reportClipped, err := svc.QueryReportIsClipped(ctx, id, appConfig.ServerSettings.GetQueryReportCap()) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "check query report is clipped") + } + return queryReportResults, reportClipped, nil } -func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) { +func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) { query, err := svc.ds.Query(ctx, queryID) if err != nil { setAuthCheckedOnPreAuthErr(ctx) @@ -191,7 +201,7 @@ func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (boo if err != nil { return false, err } - return count >= fleet.MaxQueryReportRows, nil + return count >= maxQueryReportRows, nil } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/queries_test.go b/server/service/queries_test.go index fc8631264b..95ae244d7a 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -644,7 +644,7 @@ func TestQueryAuth(t *testing.T) { _, err = svc.GetQuery(ctx, tt.qid) checkAuthErr(t, tt.shouldFailRead, err) - _, err = svc.QueryReportIsClipped(ctx, tt.qid) + _, err = svc.QueryReportIsClipped(ctx, tt.qid, fleet.DefaultMaxQueryReportRows) checkAuthErr(t, tt.shouldFailRead, err) _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false) @@ -688,15 +688,15 @@ func TestQueryReportIsClipped(t *testing.T) { return 0, nil } - isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1) + isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) require.False(t, isClipped) ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { - return fleet.MaxQueryReportRows, nil + return fleet.DefaultMaxQueryReportRows, nil } - isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1) + isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1, fleet.DefaultMaxQueryReportRows) require.NoError(t, err) require.True(t, isClipped) } @@ -725,9 +725,10 @@ func TestQueryReportReturnsNilIfDiscardDataIsTrue(t *testing.T) { }, nil } - results, err := svc.GetQueryReportResults(viewerCtx, 1) + results, reportClipped, err := svc.GetQueryReportResults(viewerCtx, 1) require.NoError(t, err) require.Nil(t, results) + require.False(t, reportClipped) } func TestComparePlatforms(t *testing.T) { diff --git a/server/service/scripts.go b/server/service/scripts.go index 4bf2d85297..b9d8fec23a 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -915,26 +915,37 @@ type lockHostRequest struct { } type lockHostResponse struct { - Err error `json:"error,omitempty"` + Err error `json:"error,omitempty"` + UnlockPIN string `json:"unlock_pin,omitempty"` + StatusCode int `json:"-"` } -func (r lockHostResponse) Status() int { return http.StatusNoContent } +func (r lockHostResponse) Status() int { + if r.StatusCode != 0 { + return r.StatusCode + } + return http.StatusNoContent +} func (r lockHostResponse) error() error { return r.Err } func lockHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*lockHostRequest) - if err := svc.LockHost(ctx, req.HostID); err != nil { + unlockPIN, err := svc.LockHost(ctx, req.HostID) + if err != nil { return lockHostResponse{Err: err}, nil } + if unlockPIN != "" { + return lockHostResponse{UnlockPIN: unlockPIN, StatusCode: http.StatusOK}, nil + } return lockHostResponse{}, nil } -func (svc *Service) LockHost(ctx context.Context, hostID uint) error { +func (svc *Service) LockHost(ctx context.Context, hostID uint) (string, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + return "", fleet.ErrMissingLicense } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/users.go b/server/service/users.go index 795fbb02d6..21a10432f7 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -34,38 +34,43 @@ type createUserRequest struct { type createUserResponse struct { User *fleet.User `json:"user,omitempty"` - Err error `json:"error,omitempty"` + // Token is only returned when creating API-only (non-SSO) users. + Token *string `json:"token,omitempty"` + Err error `json:"error,omitempty"` } func (r createUserResponse) error() error { return r.Err } func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*createUserRequest) - user, err := svc.CreateUser(ctx, req.UserPayload) + user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil } - return createUserResponse{User: user}, nil + return createUserResponse{ + User: user, + Token: sessionKey, + }, nil } -func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) { +func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, *string, error) { var teams []fleet.UserTeam if p.Teams != nil { teams = *p.Teams } if err := svc.authz.Authorize(ctx, &fleet.User{Teams: teams}, fleet.ActionWrite); err != nil { - return nil, err + return nil, nil, err } if err := p.VerifyAdminCreate(); err != nil { - return nil, ctxerr.Wrap(ctx, err, "verify user payload") + return nil, nil, ctxerr.Wrap(ctx, err, "verify user payload") } if teams != nil { // Validate that the teams exist teamsSummary, err := svc.ds.TeamsSummary(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "fetching teams in attempt to verify team exists") + return nil, nil, ctxerr.Wrap(ctx, err, "fetching teams in attempt to verify team exists") } teamIDs := map[uint]struct{}{} for _, team := range teamsSummary { @@ -74,7 +79,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet for _, userTeam := range teams { _, ok := teamIDs[userTeam.Team.ID] if !ok { - return nil, ctxerr.Wrap( + return nil, nil, ctxerr.Wrap( ctx, fleet.NewInvalidArgumentError("teams.id", fmt.Sprintf("team with id %d does not exist", userTeam.Team.ID)), ) } @@ -82,7 +87,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet } if invite, err := svc.ds.InviteByEmail(ctx, *p.Email); err == nil && invite != nil { - return nil, ctxerr.Errorf(ctx, "%s already invited", *p.Email) + return nil, nil, ctxerr.Errorf(ctx, "%s already invited", *p.Email) } if p.AdminForcedPasswordReset == nil { @@ -90,7 +95,28 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet p.AdminForcedPasswordReset = ptr.Bool(true) } - return svc.NewUser(ctx, p) + user, err := svc.NewUser(ctx, p) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "create user") + } + + // The sessionKey is returned for API-only non-SSO users only. + var sessionKey *string + if user.APIOnly && !user.SSOEnabled { + if p.Password == nil { + // Should not happen but let's log just in case. + level.Error(svc.logger).Log("err", err, "msg", "password not set during admin user creation") + } else { + // Create a session for the API-only user by logging in. + _, session, err := svc.Login(ctx, user.Email, *p.Password) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "create session for api-only user") + } + sessionKey = &session.Key + } + } + + return user, sessionKey, nil } //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/users_test.go b/server/service/users_test.go index d47e1bacd5..2f935d1c83 100644 --- a/server/service/users_test.go +++ b/server/service/users_test.go @@ -367,7 +367,7 @@ func TestUserAuth(t *testing.T) { } teams := []fleet.UserTeam{{Team: fleet.Team{ID: teamID}, Role: fleet.RoleMaintainer}} - _, err = svc.CreateUser(ctx, fleet.UserPayload{ + _, _, err = svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Name"), Email: ptr.String("some@email.com"), Password: ptr.String(test.GoodPassword), @@ -375,7 +375,7 @@ func TestUserAuth(t *testing.T) { }) checkAuthErr(t, tt.shouldFailTeamWrite, err) - _, err = svc.CreateUser(ctx, fleet.UserPayload{ + _, _, err = svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Name"), Email: ptr.String("some@email.com"), Password: ptr.String(test.GoodPassword), @@ -641,6 +641,7 @@ func TestUsersWithDS(t *testing.T) { {"CreateUserForcePasswdReset", testUsersCreateUserForcePasswdReset}, {"ChangePassword", testUsersChangePassword}, {"RequirePasswordReset", testUsersRequirePasswordReset}, + {"UsersCreateUserWithAPIOnly", testUsersCreateUserWithAPIOnly}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -668,13 +669,14 @@ func testUsersCreateUserForcePasswdReset(t *testing.T, ds *mysql.Datastore) { // As the admin, create a new user. ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) - user, err := svc.CreateUser(ctx, fleet.UserPayload{ + user, sessionKey, err := svc.CreateUser(ctx, fleet.UserPayload{ Name: ptr.String("Some Observer"), Email: ptr.String("some-observer@email.com"), Password: ptr.String(test.GoodPassword), GlobalRole: ptr.String(fleet.RoleObserver), }) require.NoError(t, err) + require.Nil(t, sessionKey) // only set when creating API-only users user, err = ds.UserByID(context.Background(), user.ID) require.NoError(t, err) @@ -1319,3 +1321,50 @@ func TestTeamAdminAddRoleOtherTeam(t *testing.T) { require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) require.False(t, ds.SaveUserFuncInvoked) } + +func testUsersCreateUserWithAPIOnly(t *testing.T, ds *mysql.Datastore) { + svc, ctx := newTestService(t, ds, nil, nil) + + host, err := ds.NewHost(ctx, &fleet.Host{ + UUID: "uuid-42", + OsqueryHostID: ptr.String("osquery_host_id-42"), + }) + require.NoError(t, err) + + // Create admin user. + admin := &fleet.User{ + Name: "Fleet Admin", + Email: "admin@foo.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + } + err = admin.SetPassword(test.GoodPassword, 10, 10) + require.NoError(t, err) + admin, err = ds.NewUser(ctx, admin) + require.NoError(t, err) + + // As the admin, create a new API-only user. + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + apiOnlyUser, sessionKey, err := svc.CreateUser(ctx, fleet.UserPayload{ + Name: ptr.String("Some Observer"), + Email: ptr.String("some-observer@email.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: ptr.Bool(true), + }) + require.NoError(t, err) + require.NotNil(t, sessionKey) + require.NotEmpty(t, *sessionKey) + + sessions, err := svc.GetInfoAboutSessionsForUser(ctx, apiOnlyUser.ID) + require.NoError(t, err) + require.Len(t, sessions, 1) + session := sessions[0] + require.Equal(t, *sessionKey, session.Key) + + refreshCtx(t, ctx, apiOnlyUser, ds, session) + + hosts, err := svc.ListHosts(ctx, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, hosts, 1) + require.Equal(t, host.ID, hosts[0].ID) +} diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index cf18ae367b..2a19a9cf91 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -72,7 +72,7 @@ func TestMacosSetupAssistant(t *testing.T) { DEPClient: apple_mdm.NewDEPClient(depStorage, ds, logger), } - const defaultProfileName = "FleetDM default enrollment profile" + const defaultProfileName = "Fleet default enrollment profile" // track the profile assigned to each device serialsToProfile := map[string]string{ diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 3babb18073..79a8c47a76 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -12,6 +12,7 @@ github.com/fleetdm/fleet/v4/server/fleet/ServerSettings DeferredSaveHost bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings QueryReportsDisabled bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings ScriptsDisabled bool github.com/fleetdm/fleet/v4/server/fleet/ServerSettings AIFeaturesDisabled bool +github.com/fleetdm/fleet/v4/server/fleet/ServerSettings QueryReportCap int github.com/fleetdm/fleet/v4/server/fleet/AppConfig SMTPSettings *fleet.SMTPSettings github.com/fleetdm/fleet/v4/server/fleet/SMTPSettings SMTPEnabled bool github.com/fleetdm/fleet/v4/server/fleet/SMTPSettings SMTPConfigured bool diff --git a/website/api/controllers/create-vanta-authorization-request.js b/website/api/controllers/create-vanta-authorization-request.js index 30fb4dbf15..8bb2c89c63 100644 --- a/website/api/controllers/create-vanta-authorization-request.js +++ b/website/api/controllers/create-vanta-authorization-request.js @@ -19,6 +19,10 @@ module.exports = { fleetApiKey: { type: 'string', required: true, + }, + redirectToExternalPageAfterAuthorization: { + type: 'string', + description: 'If provided, the user will be sent to this URL after they complete the setup of this integration' } }, @@ -59,7 +63,6 @@ module.exports = { }, fn: async function (inputs) { - let url = require('url'); // Look for any existing VantaConnection records that use this fleet instance URL. @@ -139,17 +142,26 @@ module.exports = { fleetApiKey: inputs.fleetApiKey, }); } - + let callbackUrl = `/vanta-authorization`; + if(inputs.redirectToExternalPageAfterAuthorization){ + callbackUrl += `?redirectAfterSetup=${inputs.redirectToExternalPageAfterAuthorization}`; + } // Build the authorization URL for this request. - let vantaAuthorizationRequestURL = `https://app.vanta.com/oauth/authorize?client_id=${encodeURIComponent(sails.config.custom.vantaAuthorizationClientId)}&scope=connectors.self:write-resource connectors.self:read-resource&state=${encodeURIComponent(generatedStateForThisRequest)}&source_id=${encodeURIComponent(sourceIDForThisRequest)}&redirect_uri=${encodeURIComponent(url.resolve(sails.config.custom.baseUrl, '/vanta-authorization'))}&response_type=code`; + let vantaAuthorizationRequestURL = `https://app.vanta.com/oauth/authorize?client_id=${encodeURIComponent(sails.config.custom.vantaAuthorizationClientId)}&scope=connectors.self:write-resource connectors.self:read-resource&state=${encodeURIComponent(generatedStateForThisRequest)}&source_id=${encodeURIComponent(sourceIDForThisRequest)}&redirect_uri=${encodeURIComponent(url.resolve(sails.config.custom.baseUrl, callbackUrl))}&response_type=code`; - // Set a `state` cookie on the user's browser. This value will be checked against a query parameter when the user returns to fleetdm.com. - this.res.cookie('state', generatedStateForThisRequest, {signed: true}); + if(inputs.redirectToExternalPageAfterAuthorization){ + let internalRedirectUrl = `${sails.config.custom.baseUrl}/redirect-vanta-authorization-request?vantaSourceId=${encodeURIComponent(sourceIDForThisRequest)}&state=${encodeURIComponent(generatedStateForThisRequest)}&vantaAuthorizationRequestURL=${encodeURIComponent(vantaAuthorizationRequestURL)}&redirectAfterSetup=${encodeURIComponent(inputs.redirectToExternalPageAfterAuthorization)}`; - // Set the sourceId to a cookie, we'll use this value to find the database record we created for this request when the user returns to fleetdm.com. - this.res.cookie('vantaSourceId', sourceIDForThisRequest, {signed: true}); - - return vantaAuthorizationRequestURL; + return internalRedirectUrl; + // If the useInternalRedirect input was provided, we'll return the URL of an internal endpoiint that will set the required cookies for this request. + } else { + // Otherwise, if this request came from a user on the connect-vanta page, we'll set the cookies are redirect them directly to Vanta. + // Set a `state` cookie on the user's browser. This value will be checked against a query parameter when the user returns to fleetdm.com. + this.res.cookie('state', generatedStateForThisRequest, {signed: true}); + // Set the sourceId to a cookie, we'll use this value to find the database record we created for this request when the user returns to fleetdm.com. + this.res.cookie('vantaSourceId', sourceIDForThisRequest, {signed: true}); + return vantaAuthorizationRequestURL; + } } diff --git a/website/api/controllers/redirect-vanta-authorization-request.js b/website/api/controllers/redirect-vanta-authorization-request.js new file mode 100644 index 0000000000..82e380342f --- /dev/null +++ b/website/api/controllers/redirect-vanta-authorization-request.js @@ -0,0 +1,61 @@ +module.exports = { + + + friendlyName: 'Redirect vanta authorization request', + + + description: 'Sets provided inputs in the user`s browser as cookies and redirects them to Vanta.', + + + inputs: { + vantaSourceId: { + type: 'string', + description: 'The generated vanta Source ID for this request.', + required: true, + }, + state: { + type: 'string', + description: 'The state provided to Vanta when an authorization request was created', + required: true, + }, + vantaAuthorizationRequestURL: { + type: 'string', + description: 'The Vanta authorization url that the user will be directed to after they are sent to this page.', + required: true, + }, + redirectAfterSetup: { + type: 'string', + description: 'The URL that the user will be redirected to after they complete setup.', + required: true, + } + }, + + + exits: { + noMatchingVantaConnection: { + description: 'No Vanta connection could be found using the provided vantaSourceId', + responseType: 'badRequest' + }, + }, + + + fn: async function ({vantaSourceId, state, vantaAuthorizationRequestURL, redirectAfterSetup}) { + + // Find the VantaConnection record that we created when the user created this request. + let recordOfThisAuthorization = await VantaConnection.findOne({vantaSourceId: vantaSourceId}); + + // If no record of this authorization could be found, return a noMatchingVantaConnection response. + if(!recordOfThisAuthorization){ + throw 'noMatchingVantaConnection'; + } + + // Set a 'state' and 'vantaSourceId' cookie on the users browser. + this.res.cookie('redirectAfterSetup', redirectAfterSetup, {signed: true}); + this.res.cookie('state', state, {signed: true}); + this.res.cookie('vantaSourceId', vantaSourceId, {signed: true}); + // now that the user has the required cookies to complete the vanta integration setup, redirect them to the provided VantaAuthorizationUrl. + return this.res.redirect(vantaAuthorizationRequestURL); + } + + +}; diff --git a/website/api/controllers/view-vanta-authorization.js b/website/api/controllers/view-vanta-authorization.js index 3fd465bb0b..cf6bb03afd 100644 --- a/website/api/controllers/view-vanta-authorization.js +++ b/website/api/controllers/view-vanta-authorization.js @@ -89,7 +89,9 @@ module.exports = { if(!updatedRecord){ throw new Error(`When trying to update a VantaConnection record (id: ${recordOfThisAuthorization.id}) with an authorization token from Vanta, the database record associated with this request has gone missing.`); } - + if(this.req.signedCookies.redirectAfterSetup){ + return this.res.redirect(this.req.signedCookies.redirectAfterSetup); + } return { showSuccessMessage: true }; diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc index 94cfe4e273..96d18f3427 100644 --- a/website/assets/.eslintrc +++ b/website/assets/.eslintrc @@ -48,7 +48,7 @@ "moment": true, "docsearch": true, "Chart": true, - // "google": true, + "gtag": true, // ...etc. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/website/assets/images/permanent/mdm-migration-sonoma-1500x938.png b/website/assets/images/permanent/mdm-migration-sonoma-1500x938.png new file mode 100644 index 0000000000..494d04ea3c Binary files /dev/null and b/website/assets/images/permanent/mdm-migration-sonoma-1500x938.png differ diff --git a/website/assets/js/pages/contact.page.js b/website/assets/js/pages/contact.page.js index 3a4314bf01..5b8865b67c 100644 --- a/website/assets/js/pages/contact.page.js +++ b/website/assets/js/pages/contact.page.js @@ -80,13 +80,18 @@ parasails.registerPage('contact', { methods: { submittedContactForm: async function() { - + if(typeof gtag !== 'undefined'){ + gtag('event','website_contact_forms'); + } // Show the success message. this.cloudSuccess = true; }, submittedTalkToUsForm: async function() { this.syncing = true; + if(typeof gtag !== 'undefined'){ + gtag('event','website_contact_forms'); + } if(this.formData.numberOfHosts > 700){ this.goto(`https://calendly.com/fleetdm/talk-to-us?email=${encodeURIComponent(this.formData.emailAddress)}&name=${encodeURIComponent(this.formData.firstName+' '+this.formData.lastName)}`); } else { diff --git a/website/assets/js/pages/docs/basic-documentation.page.js b/website/assets/js/pages/docs/basic-documentation.page.js index 5b7262b8f2..a2540321f7 100644 --- a/website/assets/js/pages/docs/basic-documentation.page.js +++ b/website/assets/js/pages/docs/basic-documentation.page.js @@ -222,6 +222,13 @@ parasails.registerPage('basic-documentation', { // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { + clickSwagRequestCTA: function () { + if(typeof gtag !== 'undefined') { + gtag('event','website_swag_request'); + } + this.goto('https://kqphpqst851.typeform.com/to/ZfA3sOu0'); + }, + clickCTA: function (slug) { this.goto(slug); }, diff --git a/website/assets/js/pages/entrance/signup.page.js b/website/assets/js/pages/entrance/signup.page.js index 598e3b073e..4f0904a935 100644 --- a/website/assets/js/pages/entrance/signup.page.js +++ b/website/assets/js/pages/entrance/signup.page.js @@ -67,6 +67,9 @@ parasails.registerPage('signup', { // redirect to the /start page. // > (Note that we re-enable the syncing state here. This is on purpose-- // > to make sure the spinner stays there until the page navigation finishes.) + if(typeof gtag !== 'undefined'){ + gtag('event','website_sign_up'); + } this.syncing = true; this.goto(this.pageToRedirectToAfterRegistration);// « / start if the user came here from the start now button, or customers/new-license if the user came here from the "Get your license" link. } diff --git a/website/config/policies.js b/website/config/policies.js index 2c46a7f477..5d4552459d 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -55,4 +55,5 @@ module.exports.policies = { 'deliver-talk-to-us-form-submission': true, 'get-human-interpretation-from-osquery-sql': true, 'customers/view-new-license': true, + 'redirect-vanta-authorization-request': true, }; diff --git a/website/config/routes.js b/website/config/routes.js index 7814ed055c..7a07552257 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -589,7 +589,8 @@ module.exports.routes = { 'POST /api/v1/create-or-update-one-newsletter-subscription': { action: 'create-or-update-one-newsletter-subscription' }, '/api/v1/unsubscribe-from-all-newsletters': { action: 'unsubscribe-from-all-newsletters' }, 'POST /api/v1/admin/build-license-key': { action: 'admin/build-license-key' }, - 'POST /api/v1/create-vanta-authorization-request': { action: 'create-vanta-authorization-request' }, + 'POST /api/v1/create-vanta-authorization-request': { action: 'create-vanta-authorization-request', csrf: false }, + 'GET /redirect-vanta-authorization-request': { action: 'redirect-vanta-authorization-request' }, 'POST /api/v1/deliver-mdm-beta-signup': { action: 'deliver-mdm-beta-signup' }, 'POST /api/v1/get-human-interpretation-from-osquery-sql': { action: 'get-human-interpretation-from-osquery-sql', csrf: false }, 'POST /api/v1/deliver-apple-csr ': { action: 'deliver-apple-csr', csrf: false}, diff --git a/website/views/pages/docs/basic-documentation.ejs b/website/views/pages/docs/basic-documentation.ejs index 8e6853c7b7..eb47296580 100644 --- a/website/views/pages/docs/basic-documentation.ejs +++ b/website/views/pages/docs/basic-documentation.ejs @@ -157,7 +157,7 @@ Releases Support