diff --git a/changes/13643-fleetctl-gitops b/changes/13643-fleetctl-gitops new file mode 100644 index 000000000..be7855dec --- /dev/null +++ b/changes/13643-fleetctl-gitops @@ -0,0 +1,2 @@ +Added fleetctl gitops command: +- Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow. diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index 939fe0ec8..d44a5762b 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -77,7 +77,7 @@ func applyCommand() *cli.Command { opts.TeamForPolicies = policiesTeamName } baseDir := filepath.Dir(flFilename) - err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts) + _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts) if err != nil { return err } diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 5b6b9682d..3c7e99b1b 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -265,7 +265,7 @@ spec: }, } - require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n") // enroll secret not provided, so left unchanged assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)]) assert.False(t, ds.ApplyEnrollSecretsFuncInvoked) @@ -379,7 +379,7 @@ spec: }, } - require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n") // agent options still cleared assert.Nil(t, teamsByName["team1"].Config.AgentOptions) // macos settings and updates are now cleared. @@ -869,12 +869,12 @@ func TestApplyPolicies(t *testing.T) { func TestApplyPoliciesValidation(t *testing.T) { // Team Policy Spec filename := writeTmpYml(t, duplicateTeamPolicySpec) - errorMsg := `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.` + errorMsg := `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.` runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg) // Global Policy Spec filename = writeTmpYml(t, duplicateGlobalPolicySpec) - errorMsg = `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.` + errorMsg = `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.` runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg) } @@ -1168,10 +1168,10 @@ spec: `, mobileConfigPath)) // first apply with dry-run - require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n") // then apply for real - require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n") assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions)) assert.Equal(t, fleet.TeamMDM{ EnableDiskEncryption: false, @@ -1204,10 +1204,10 @@ spec: `, emptySetupAsst)) // first apply with dry-run - require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n") // then apply for real - require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n") require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked) require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) require.True(t, ds.NewJobFuncInvoked) @@ -1243,10 +1243,10 @@ spec: `, bootstrapURL)) // first apply with dry-run - require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n") // then apply for real - require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n") // all left untouched, only bootstrap package added assert.Equal(t, fleet.TeamMDM{ EnableDiskEncryption: false, diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go index 6bfa82820..b256bc4fa 100644 --- a/cmd/fleetctl/fleetctl.go +++ b/cmd/fleetctl/fleetctl.go @@ -108,6 +108,7 @@ func createApp( mdmCommand(), upgradePacksCommand(), runScriptCommand(), + gitopsCommand(), } return app } diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index ee62c50be..5f31b5020 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2214,7 +2214,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"}) yamlFilePath := writeTmpYml(t, actualYaml) - require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath})) + assert.Contains(t, runAppForTest(t, []string{"apply", "-f", yamlFilePath}), "[+] applied 2 teams\n") } func TestGetMDMCommandResults(t *testing.T) { diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go new file mode 100644 index 000000000..20775c1ed --- /dev/null +++ b/cmd/fleetctl/gitops.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/pkg/spec" + "github.com/urfave/cli/v2" + "os" + "path/filepath" +) + +func gitopsCommand() *cli.Command { + var ( + flFilename string + flDryRun bool + ) + return &cli.Command{ + Name: "gitops", + Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.", + UsageText: `fleetctl gitops [options]`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "f", + EnvVars: []string{"FILENAME"}, + Value: "", + Destination: &flFilename, + Usage: "The file with the GitOps configuration", + }, + &cli.BoolFlag{ + Name: "dry-run", + EnvVars: []string{"DRY_RUN"}, + Destination: &flDryRun, + Usage: "Do not apply the file, just validate it", + }, + configFlag(), + contextFlag(), + debugFlag(), + }, + Action: func(c *cli.Context) error { + if flFilename == "" { + return errors.New("-f must be specified") + } + b, err := os.ReadFile(flFilename) + if err != nil { + return err + } + fleetClient, err := clientFromCLI(c) + if err != nil { + return err + } + baseDir := filepath.Dir(flFilename) + config, err := spec.GitOpsFromBytes(b, baseDir) + if err != nil { + return err + } + logf := func(format string, a ...interface{}) { + _, _ = fmt.Fprintf(c.App.Writer, format, a...) + } + err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun) + if err != nil { + return err + } + if flDryRun { + _, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n") + } else { + _, _ = fmt.Fprintf(c.App.Writer, "[!] gitops succeeded\n") + } + return nil + }, + } +} diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go new file mode 100644 index 000000000..901006198 --- /dev/null +++ b/cmd/fleetctl/gitops_test.go @@ -0,0 +1,516 @@ +package main + +import ( + "context" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + nanomdm_mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/micromdm/nanodep/tokenpki" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "slices" + "strings" + "testing" + "time" +) + +const teamName = "Team Test" + +func TestBasicGlobalGitOps(t *testing.T) { + _, ds := runServerWithMockedDS(t) + + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + var enrolledSecrets []*fleet.EnrollSecret + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + enrolledSecrets = secrets + return nil + } + + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + ) + t.Setenv("FLEET_SERVER_URL", fleetServerURL) + + _, err = tmpFile.WriteString( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: +`, + ) + require.NoError(t, err) + + // No file + var errWriter strings.Builder + _, err = runAppNoChecks([]string{"gitops", tmpFile.Name()}) + assert.Error(t, err) + assert.Equal(t, err.Error(), "-f must be specified") + + // Bad file + errWriter.Reset() + _, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no such file or directory") + + // Empty file + errWriter.Reset() + badFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "errors occurred") + + // DoGitOps error + t.Setenv("ORG_NAME", "") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "organization name must be present") + + // Dry run + t.Setenv("ORG_NAME", orgName) + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Empty(t, enrolledSecrets) +} + +func TestBasicTeamGitOps(t *testing.T) { + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + }, + ) + + const secret = "TestSecret" + + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return nil, nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + team := &fleet.Team{ + ID: 1, + CreatedAt: time.Now(), + Name: teamName, + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == teamName { + return team, nil + } + return nil, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == team.ID { + return team, nil + } + return nil, nil + } + var savedTeam *fleet.Team + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + + var enrolledSecrets []*fleet.EnrollSecret + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + enrolledSecrets = secrets + return nil + } + + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + t.Setenv("TEST_SECRET", secret) + + _, err = tmpFile.WriteString( + ` +controls: +queries: +policies: +agent_options: +name: ${TEST_TEAM_NAME} +team_settings: + secrets: [{"secret":"${TEST_SECRET}"}] +`, + ) + require.NoError(t, err) + + // DoGitOps error + t.Setenv("TEST_TEAM_NAME", "") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "'name' is required") + + // Dry run + t.Setenv("TEST_TEAM_NAME", teamName) + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + assert.Nil(t, savedTeam) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledSecrets, 1) + assert.Equal(t, secret, enrolledSecrets[0].Secret) +} + +func TestFullGlobalGitOps(t *testing.T) { + // mdm test configuration must be set so that activating windows MDM works. + testCert, testKey, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + testCertPEM := tokenpki.PEMCertificate(testCert.Raw) + testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) + fleetCfg := config.TestConfig() + config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata") + + // License is not needed because we are not using any premium features in our config. + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + MDMStorage: new(nanomdm_mock.Storage), + MDMPusher: mockPusher{}, + FleetConfig: &fleetCfg, + }, + ) + + var appliedScripts []*fleet.Script + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + appliedScripts = scripts + return nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + var appliedMacProfiles []*fleet.MDMAppleConfigProfile + var appliedWinProfiles []*fleet.MDMWindowsConfigProfile + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + ) error { + appliedMacProfiles = macProfiles + appliedWinProfiles = winProfiles + return nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { + return nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return job, nil + } + + // Policies + policy := fleet.Policy{} + policy.ID = 1 + policy.Name = "Policy to delete" + policyDeleted := false + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { + return []*fleet.Policy{&policy}, nil + } + ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { + if slices.Contains(ids, 1) { + return map[uint]*fleet.Policy{1: &policy}, nil + } + return nil, nil + } + ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) { + policyDeleted = true + assert.Equal(t, []uint{policy.ID}, ids) + return ids, nil + } + var appliedPolicySpecs []*fleet.PolicySpec + ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + appliedPolicySpecs = specs + return nil + } + + // Queries + query := fleet.Query{} + query.ID = 1 + query.Name = "Query to delete" + queryDeleted := false + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { + return []*fleet.Query{&query}, nil + } + ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { + queryDeleted = true + assert.Equal(t, []uint{query.ID}, ids) + return 1, nil + } + ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { + if id == query.ID { + return &query, nil + } + return nil, nil + } + var appliedQueries []*fleet.Query + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return nil, ¬FoundError{} + } + ds.ApplyQueriesFunc = func( + ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, + ) error { + appliedQueries = queries + return nil + } + + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + var enrolledSecrets []*fleet.EnrollSecret + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + enrolledSecrets = secrets + return nil + } + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + ) + t.Setenv("FLEET_SERVER_URL", fleetServerURL) + t.Setenv("ORG_NAME", orgName) + + // Dry run + file := "./testdata/gitops/global_config_no_paths.yml" + _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + assert.Len(t, enrolledSecrets, 0) + assert.Len(t, appliedPolicySpecs, 0) + assert.Len(t, appliedQueries, 0) + assert.Len(t, appliedScripts, 0) + assert.Len(t, appliedMacProfiles, 0) + assert.Len(t, appliedWinProfiles, 0) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", file}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration") + assert.Len(t, enrolledSecrets, 2) + assert.True(t, policyDeleted) + assert.Len(t, appliedPolicySpecs, 5) + assert.True(t, queryDeleted) + assert.Len(t, appliedQueries, 3) + assert.Len(t, appliedScripts, 1) + assert.Len(t, appliedMacProfiles, 1) + assert.Len(t, appliedWinProfiles, 1) +} + +func TestFullTeamGitOps(t *testing.T) { + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + + // mdm test configuration must be set so that activating windows MDM works. + testCert, testKey, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + testCertPEM := tokenpki.PEMCertificate(testCert.Raw) + testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) + fleetCfg := config.TestConfig() + config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata") + + // License is not needed because we are not using any premium features in our config. + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + MDMStorage: new(nanomdm_mock.Storage), + MDMPusher: mockPusher{}, + FleetConfig: &fleetCfg, + }, + ) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + WindowsEnabledAndConfigured: true, + }, + }, nil + } + + var appliedScripts []*fleet.Script + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + appliedScripts = scripts + return nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + var appliedMacProfiles []*fleet.MDMAppleConfigProfile + var appliedWinProfiles []*fleet.MDMWindowsConfigProfile + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + ) error { + appliedMacProfiles = macProfiles + appliedWinProfiles = winProfiles + return nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { + return nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return job, nil + } + + // Team + team := &fleet.Team{ + ID: 1, + CreatedAt: time.Now(), + Name: teamName, + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == teamName { + return team, nil + } + return nil, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == team.ID { + return team, nil + } + return nil, nil + } + var savedTeam *fleet.Team + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + + // Policies + policy := fleet.Policy{} + policy.ID = 1 + policy.Name = "Policy to delete" + policy.TeamID = &team.ID + policyDeleted := false + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return []*fleet.Policy{&policy}, nil, nil + } + ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { + if slices.Contains(ids, 1) { + return map[uint]*fleet.Policy{1: &policy}, nil + } + return nil, nil + } + ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, IDs []uint) ([]uint, error) { + policyDeleted = true + assert.Equal(t, []uint{policy.ID}, IDs) + return []uint{policy.ID}, nil + } + var appliedPolicySpecs []*fleet.PolicySpec + ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + appliedPolicySpecs = specs + return nil + } + + // Queries + query := fleet.Query{} + query.ID = 1 + query.TeamID = &team.ID + query.Name = "Query to delete" + queryDeleted := false + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { + return []*fleet.Query{&query}, nil + } + ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { + queryDeleted = true + assert.Equal(t, []uint{query.ID}, ids) + return 1, nil + } + ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { + if id == query.ID { + return &query, nil + } + return nil, nil + } + var appliedQueries []*fleet.Query + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return nil, ¬FoundError{} + } + ds.ApplyQueriesFunc = func( + ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, + ) error { + appliedQueries = queries + return nil + } + + var enrolledSecrets []*fleet.EnrollSecret + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + enrolledSecrets = secrets + return nil + } + + t.Setenv("TEST_TEAM_NAME", teamName) + + // Dry run + file := "./testdata/gitops/team_config_no_paths.yml" + _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) + assert.Nil(t, savedTeam) + assert.Len(t, enrolledSecrets, 0) + assert.Len(t, appliedPolicySpecs, 0) + assert.Len(t, appliedQueries, 0) + assert.Len(t, appliedScripts, 0) + assert.Len(t, appliedMacProfiles, 0) + assert.Len(t, appliedWinProfiles, 0) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", file}) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration") + assert.True(t, savedTeam.Config.Features.EnableHostUsers) + assert.Equal(t, 30, savedTeam.Config.HostExpirySettings.HostExpiryWindow) + assert.True(t, savedTeam.Config.MDM.EnableDiskEncryption) + assert.Len(t, enrolledSecrets, 2) + assert.True(t, policyDeleted) + assert.Len(t, appliedPolicySpecs, 5) + assert.True(t, queryDeleted) + assert.Len(t, appliedQueries, 3) + assert.Len(t, appliedScripts, 1) + assert.Len(t, appliedMacProfiles, 1) + assert.Len(t, appliedWinProfiles, 1) +} diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index 77f20b9cb..5b61b1d62 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -342,7 +342,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st } // this only applies standard queries, the base directory is not used, // so pass in the current working directory. - err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{}) + _, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{}) if err != nil { return err } diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml new file mode 100644 index 000000000..7d0b81e96 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -0,0 +1,175 @@ +# Test config +controls: # Controls added to "No team" + macos_settings: + custom_settings: + - path: ./lib/macos-password.mobileconfig + windows_settings: + custom_settings: + - path: ./lib/windows-screenlock.xml + scripts: + - path: ./lib/collect-fleetd-logs.sh + enable_disk_encryption: false + macos_migration: + enable: false + mode: "" + webhook_url: "" + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_enabled_and_configured: true + windows_updates: + deadline_days: null + grace_period_days: null +queries: + - name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot + - name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; + - name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true + - name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; +agent_options: + command_line_flags: + distributed_denylist_duration: 0 + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +org_settings: + server_settings: + debug_host_ids: + - 10728 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_reports_disabled: false + scripts_disabled: false + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: $ORG_NAME + smtp_settings: + authentication_method: authmethod_plain + authentication_type: authtype_username_password + configured: false + domain: "" + enable_smtp: false + enable_ssl_tls: true + enable_start_tls: true + password: "" + port: 587 + sender_address: "" + server: "" + user_name: "" + verify_ssl_certs: true + sso_settings: + enable_jit_provisioning: false + enable_jit_role_sync: false + enable_sso: true + enable_sso_idp_login: false + entity_id: https://saml.example.com/entityid + idp_image_url: "" + idp_name: MockSAML + issuer_uri: "" + metadata: "" + metadata_url: https://mocksaml.com/api/saml/metadata + integrations: + jira: [] + zendesk: [] + mdm: + apple_bm_default_team: "" + end_user_authentication: + entity_id: "" + idp_name: "" + issuer_uri: "" + metadata: "" + metadata_url: "" + webhook_settings: + failing_policies_webhook: + destination_url: https://host.docker.internal:8080/bozo + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: [] + host_status_webhook: + days_count: 0 + destination_url: "" + enable_host_status_webhook: false + host_percentage: 0 + interval: 24h0m0s + vulnerabilities_webhook: + destination_url: "" + enable_vulnerabilities_webhook: false + host_batch_size: 0 + fleet_desktop: # Applies to Fleet Premium only + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: # Applies to all teams + host_expiry_enabled: false + features: # Features added to all teams + enable_host_users: true + enable_software_inventory: true + vulnerability_settings: + databases_path: "" + secrets: # These secrets are used to enroll hosts to the "All teams" team + - secret: SampleSecret123 + - secret: ABC diff --git a/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh b/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh new file mode 100644 index 000000000..887af2ace --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh @@ -0,0 +1,7 @@ +cp /var/log/orbit/orbit.stderr.log ~/Library/Logs/Fleet/fleet-desktop.log /Users/Shared + +echo "Successfully copied fleetd logs to the /Users/Shared folder." + +echo "To retrieve logs, ask the end user to open Finder and in the menu bar select Go > Go to Folder." + +echo "Then, ask the end user to type in /Users/Shared, press Return, and locate orbit.stderr.log (Orbit logs) and fleet-desktop.log (Fleet Desktop logs) files." \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig b/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig new file mode 100644 index 000000000..2fe2f717d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig @@ -0,0 +1,55 @@ + + + + + PayloadContent + + + PayloadDescription + Configures Passcode settings + PayloadDisplayName + Passcode + PayloadIdentifier + com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E.com.apple.mobiledevice.passwordpolicy.231DFC90-D5A7-41B8-9246-564056048AC5 + PayloadOrganization + + PayloadType + com.apple.mobiledevice.passwordpolicy + PayloadUUID + 231DFC90-D5A7-41B8-9246-564056048AC5 + PayloadVersion + 1 + allowSimple + + forcePIN + + maxFailedAttempts + 11 + maxGracePeriod + 1 + maxInactivity + 15 + minLength + 10 + requireAlphanumeric + + + + PayloadDescription + Configures our Macs to require passwords that are 10 character long + PayloadDisplayName + Password policy - require 10 characters + PayloadIdentifier + com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E + PayloadOrganization + FleetDM + PayloadScope + System + PayloadType + Configuration + PayloadUUID + F7CF282E-D91B-44E9-922F-A719634F9C8E + PayloadVersion + 1 + + \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml b/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml new file mode 100644 index 000000000..3d7d52ded --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml @@ -0,0 +1,48 @@ + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/DevicePasswordEnabled + + 0 + + + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MaxInactivityTimeDeviceLock + + 15 + + + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordLength + + 10 + + + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordComplexCharacters + + 2 + + diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml new file mode 100644 index 000000000..6a4fbdf2d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -0,0 +1,111 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "SampleSecret123" + - secret: "ABC" + webhook_settings: + failing_policies_webhook: + enable_failing_policies_webhook: true + destination_url: https://example.tines.com/webhook + policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9] + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: + command_line_flags: + distributed_denylist_duration: 0 + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +controls: + macos_settings: + custom_settings: + - path: ./lib/macos-password.mobileconfig + windows_settings: + custom_settings: + - path: ./lib/windows-screenlock.xml + scripts: + - path: ./lib/collect-fleetd-logs.sh + enable_disk_encryption: true + macos_migration: + enable: false + mode: "" + webhook_url: "" + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_enabled_and_configured: true + windows_updates: + deadline_days: null + grace_period_days: null +queries: + - name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot + - name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; + - name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true + - name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 0e7fd4cda..ffb5a908e 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -804,24 +804,22 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, }) } - if applyOpts.DryRun { - return nil, nil - } - idsByName := make(map[string]uint, len(details)) if len(details) > 0 { for _, tm := range details { idsByName[tm.Name] = tm.ID } - if err := svc.ds.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeAppliedSpecTeam{ - Teams: details, - }, - ); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for team spec") + if !applyOpts.DryRun { + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeAppliedSpecTeam{ + Teams: details, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for team spec") + } } } return idsByName, nil diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go new file mode 100644 index 000000000..adf02a352 --- /dev/null +++ b/pkg/spec/gitops.go @@ -0,0 +1,492 @@ +package spec + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/ghodss/yaml" + "github.com/hashicorp/go-multierror" + "os" + "path/filepath" + "slices" + "unicode" +) + +type BaseItem struct { + Path *string `json:"path"` +} + +type Controls struct { + BaseItem + MacOSUpdates interface{} `json:"macos_updates"` + MacOSSettings interface{} `json:"macos_settings"` + MacOSSetup interface{} `json:"macos_setup"` + MacOSMigration interface{} `json:"macos_migration"` + + WindowsUpdates interface{} `json:"windows_updates"` + WindowsSettings interface{} `json:"windows_settings"` + WindowsEnabledAndConfigured interface{} `json:"windows_enabled_and_configured"` + + EnableDiskEncryption interface{} `json:"enable_disk_encryption"` + + Scripts []BaseItem `json:"scripts"` +} + +type Policy struct { + BaseItem + fleet.PolicySpec +} + +type Query struct { + BaseItem + fleet.QuerySpec +} + +type GitOps struct { + TeamID *uint + TeamName *string + TeamSettings map[string]interface{} + OrgSettings map[string]interface{} + AgentOptions *json.RawMessage + Controls Controls + Policies []*fleet.PolicySpec + Queries []*fleet.QuerySpec +} + +// GitOpsFromBytes parses a GitOps yaml file. +func GitOpsFromBytes(b []byte, baseDir string) (*GitOps, error) { + var top map[string]json.RawMessage + b = []byte(os.ExpandEnv(string(b))) // replace $var and ${var} with env values + if err := yaml.Unmarshal(b, &top); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %w: \n", err) + } + + var multiError *multierror.Error + result := &GitOps{} + + topKeys := []string{"name", "team_settings", "org_settings", "agent_options", "controls", "policies", "queries"} + for k := range top { + if !slices.Contains(topKeys, k) { + multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k)) + } + } + + // Figure out if this is an org or team settings file + teamRaw, teamOk := top["name"] + teamSettingsRaw, teamSettingsOk := top["team_settings"] + orgSettingsRaw, orgOk := top["org_settings"] + if orgOk { + if teamOk || teamSettingsOk { + multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name' or 'team_settings'")) + } else { + multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError) + } + } else if teamOk && teamSettingsOk { + multiError = parseName(teamRaw, result, multiError) + multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) + } else { + multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required")) + } + + // Validate the required top level options + multiError = parseControls(top, result, baseDir, multiError) + multiError = parseAgentOptions(top, result, baseDir, multiError) + multiError = parsePolicies(top, result, baseDir, multiError) + multiError = parseQueries(top, result, baseDir, multiError) + + return result, multiError.ErrorOrNil() +} + +func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error) *multierror.Error { + if err := json.Unmarshal(raw, &result.TeamName); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal name: %v", err)) + } + if result.TeamName == nil || *result.TeamName == "" { + return multierror.Append(multiError, errors.New("team 'name' is required")) + } + if !isASCII(*result.TeamName) { + multiError = multierror.Append(multiError, fmt.Errorf("team name must be in ASCII: %s", *result.TeamName)) + } + return multiError +} + +func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + var orgSettingsTop BaseItem + if err := json.Unmarshal(raw, &orgSettingsTop); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal org_settings: %v", err)) + } + noError := true + if orgSettingsTop.Path != nil { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path)) + if err != nil { + noError = false + multiError = multierror.Append(multiError, fmt.Errorf("failed to read org settings file %s: %v", *orgSettingsTop.Path, err)) + } else { + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathOrgSettings BaseItem + if err := yaml.Unmarshal(fileBytes, &pathOrgSettings); err != nil { + noError = false + multiError = multierror.Append( + multiError, fmt.Errorf("failed to unmarshal org settings file %s: %v", *orgSettingsTop.Path, err), + ) + } else { + if pathOrgSettings.Path != nil { + noError = false + multiError = multierror.Append( + multiError, + fmt.Errorf("nested paths are not supported: %s in %s", *pathOrgSettings.Path, *orgSettingsTop.Path), + ) + } else { + raw = fileBytes + } + } + } + } + if noError { + if err := yaml.Unmarshal(raw, &result.OrgSettings); err != nil { + // This error is currently unreachable because we know the file is valid YAML when we checked for nested path + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal org settings: %v", err)) + } else { + multiError = parseSecrets(result, multiError) + } + // TODO: Validate that integrations.(jira|zendesk)[].api_token is not empty or fleet.MaskedPassword + } + return multiError +} + +func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + var teamSettingsTop BaseItem + if err := json.Unmarshal(raw, &teamSettingsTop); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal team_settings: %v", err)) + } + noError := true + if teamSettingsTop.Path != nil { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *teamSettingsTop.Path)) + if err != nil { + noError = false + multiError = multierror.Append(multiError, fmt.Errorf("failed to read team settings file %s: %v", *teamSettingsTop.Path, err)) + } else { + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathTeamSettings BaseItem + if err := yaml.Unmarshal(fileBytes, &pathTeamSettings); err != nil { + noError = false + multiError = multierror.Append( + multiError, fmt.Errorf("failed to unmarshal team settings file %s: %v", *teamSettingsTop.Path, err), + ) + } else { + if pathTeamSettings.Path != nil { + noError = false + multiError = multierror.Append( + multiError, + fmt.Errorf("nested paths are not supported: %s in %s", *pathTeamSettings.Path, *teamSettingsTop.Path), + ) + } else { + raw = fileBytes + } + } + } + } + if noError { + if err := yaml.Unmarshal(raw, &result.TeamSettings); err != nil { + // This error is currently unreachable because we know the file is valid YAML when we checked for nested path + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal team settings: %v", err)) + } else { + multiError = parseSecrets(result, multiError) + } + } + return multiError +} + +func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Error { + var rawSecrets interface{} + var ok bool + if result.TeamName == nil { + rawSecrets, ok = result.OrgSettings["secrets"] + if !ok { + return multierror.Append(multiError, errors.New("'org_settings.secrets' is required")) + } + } else { + rawSecrets, ok = result.TeamSettings["secrets"] + if !ok { + return multierror.Append(multiError, errors.New("'team_settings.secrets' is required")) + } + } + var enrollSecrets []*fleet.EnrollSecret + if rawSecrets != nil { + secrets, ok := rawSecrets.([]interface{}) + if !ok { + return multierror.Append(multiError, errors.New("'secrets' must be a list of secret items")) + } + for _, enrollSecret := range secrets { + var secret string + var secretInterface interface{} + secretMap, ok := enrollSecret.(map[string]interface{}) + if ok { + secretInterface, ok = secretMap["secret"] + } + if ok { + secret, ok = secretInterface.(string) + } + if !ok || secret == "" { + multiError = multierror.Append( + multiError, errors.New("each item in 'secrets' must have a 'secret' key containing an ASCII string value"), + ) + break + } + enrollSecrets = append( + enrollSecrets, &fleet.EnrollSecret{Secret: secret}, + ) + } + } + if result.TeamName == nil { + result.OrgSettings["secrets"] = enrollSecrets + } else { + result.TeamSettings["secrets"] = enrollSecrets + } + return multiError +} + +func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + agentOptionsRaw, ok := top["agent_options"] + if !ok { + return multierror.Append(multiError, errors.New("'agent_options' is required")) + } + var agentOptionsTop BaseItem + if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal agent_options: %v", err)) + } else { + if agentOptionsTop.Path == nil { + result.AgentOptions = &agentOptionsRaw + } else { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *agentOptionsTop.Path)) + if err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to read agent options file %s: %v", *agentOptionsTop.Path, err)) + } + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathAgentOptions BaseItem + if err := yaml.Unmarshal(fileBytes, &pathAgentOptions); err != nil { + return multierror.Append( + multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err), + ) + } + if pathAgentOptions.Path != nil { + return multierror.Append( + multiError, + fmt.Errorf("nested paths are not supported: %s in %s", *pathAgentOptions.Path, *agentOptionsTop.Path), + ) + } + var raw json.RawMessage + if err := yaml.Unmarshal(fileBytes, &raw); err != nil { + // This error is currently unreachable because we know the file is valid YAML when we checked for nested path + return multierror.Append( + multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err), + ) + } + result.AgentOptions = &raw + } + } + return multiError +} + +func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + controlsRaw, ok := top["controls"] + if !ok { + return multierror.Append(multiError, errors.New("'controls' is required")) + } + var controlsTop Controls + if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err)) + } + if controlsTop.Path == nil { + result.Controls = controlsTop + } else { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *controlsTop.Path)) + if err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to read controls file %s: %v", *controlsTop.Path, err)) + } + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathControls Controls + if err := yaml.Unmarshal(fileBytes, &pathControls); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls file %s: %v", *controlsTop.Path, err)) + } + if pathControls.Path != nil { + return multierror.Append( + multiError, + fmt.Errorf("nested paths are not supported: %s in %s", *pathControls.Path, *controlsTop.Path), + ) + } + result.Controls = pathControls + } + return multiError +} + +func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + policiesRaw, ok := top["policies"] + if !ok { + return multierror.Append(multiError, errors.New("'policies' key is required")) + } + var policies []Policy + if err := json.Unmarshal(policiesRaw, &policies); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies: %v", err)) + } + for _, item := range policies { + item := item + if item.Path == nil { + result.Policies = append(result.Policies, &item.PolicySpec) + } else { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err)) + continue + } + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathPolicies []*Policy + if err := yaml.Unmarshal(fileBytes, &pathPolicies); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies file %s: %v", *item.Path, err)) + continue + } + for _, pp := range pathPolicies { + pp := pp + if pp != nil { + if pp.Path != nil { + multiError = multierror.Append( + multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path), + ) + } else { + result.Policies = append(result.Policies, &pp.PolicySpec) + } + } + } + } + } + // Make sure team name is correct, and do additional validation + for _, item := range result.Policies { + if item.Name == "" { + multiError = multierror.Append(multiError, errors.New("policy name is required for each policy")) + } + if item.Query == "" { + multiError = multierror.Append(multiError, errors.New("policy query is required for each policy")) + } + if result.TeamName != nil { + item.Team = *result.TeamName + } else { + item.Team = "" + } + } + duplicates := getDuplicateNames( + result.Policies, func(p *fleet.PolicySpec) string { + return p.Name + }, + ) + if len(duplicates) > 0 { + multiError = multierror.Append(multiError, fmt.Errorf("duplicate policy names: %v", duplicates)) + } + return multiError +} + +func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + queriesRaw, ok := top["queries"] + if !ok { + return multierror.Append(multiError, errors.New("'queries' key is required")) + } + var queries []Query + if err := json.Unmarshal(queriesRaw, &queries); err != nil { + return multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries: %v", err)) + } + for _, item := range queries { + item := item + if item.Path == nil { + result.Queries = append(result.Queries, &item.QuerySpec) + } else { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to read queries file %s: %v", *item.Path, err)) + continue + } + fileBytes = []byte(os.ExpandEnv(string(fileBytes))) + var pathQueries []*Query + if err := yaml.Unmarshal(fileBytes, &pathQueries); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries file %s: %v", *item.Path, err)) + continue + } + for _, pq := range pathQueries { + pq := pq + if pq != nil { + if pq.Path != nil { + multiError = multierror.Append( + multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path), + ) + } else { + result.Queries = append(result.Queries, &pq.QuerySpec) + } + } + } + } + } + // Make sure team name is correct and do additional validation + for _, q := range result.Queries { + if q.Name == "" { + multiError = multierror.Append(multiError, errors.New("query name is required for each query")) + } + if q.Query == "" { + multiError = multierror.Append(multiError, errors.New("query SQL query is required for each query")) + } + // Don't use non-ASCII + if !isASCII(q.Name) { + multiError = multierror.Append(multiError, fmt.Errorf("query name must be in ASCII: %s", q.Name)) + } + if result.TeamName != nil { + q.TeamName = *result.TeamName + } else { + q.TeamName = "" + } + } + duplicates := getDuplicateNames( + result.Queries, func(q *fleet.QuerySpec) string { + return q.Name + }, + ) + if len(duplicates) > 0 { + multiError = multierror.Append(multiError, fmt.Errorf("duplicate query names: %v", duplicates)) + } + return multiError +} + +func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string { + // We are using the allKeys map as a set here. True means the item is a duplicate. + allKeys := make(map[string]bool) + var duplicates []string + for _, item := range slice { + name := getComparableString(item) + if isDuplicate, exists := allKeys[name]; exists { + // If this name hasn't already been marked as a duplicate. + if !isDuplicate { + duplicates = append(duplicates, name) + } + allKeys[name] = true + } else { + allKeys[name] = false + } + } + return duplicates +} + +func isASCII(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true +} + +// resolves the paths to an absolute path relative to the baseDir, which should +// be the path of the YAML file where the relative paths were specified. If the +// path is already absolute, it is left untouched. +func resolveApplyRelativePath(baseDir string, path string) string { + if baseDir == "" || filepath.IsAbs(path) { + return path + } + return filepath.Join(baseDir, path) +} diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go new file mode 100644 index 000000000..3feea55b4 --- /dev/null +++ b/pkg/spec/gitops_test.go @@ -0,0 +1,649 @@ +package spec + +import ( + "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "slices" + "testing" +) + +var topLevelOptions = map[string]string{ + "controls": "controls:", + "queries": "queries:", + "policies": "policies:", + "agent_options": "agent_options:", + "org_settings": ` +org_settings: + server_settings: + server_url: https://fleet.example.com + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: Test Org + secrets: +`, +} + +var teamLevelOptions = map[string]string{ + "controls": "controls:", + "queries": "queries:", + "policies": "policies:", + "agent_options": "agent_options:", + "name": "name: TeamName", + "team_settings": ` +team_settings: + secrets: +`, +} + +func TestValidGitOpsYaml(t *testing.T) { + t.Parallel() + tests := map[string]struct { + filePath string + isTeam bool + }{ + "global_config_no_paths": { + filePath: "testdata/global_config_no_paths.yml", + }, + "global_config_with_paths": { + filePath: "testdata/global_config.yml", + }, + "team_config_no_paths": { + filePath: "testdata/team_config_no_paths.yml", + isTeam: true, + }, + "team_config_with_paths": { + filePath: "testdata/team_config.yml", + isTeam: true, + }, + } + + for name, test := range tests { + test := test + name := name + t.Run( + name, func(t *testing.T) { + t.Parallel() + dat, err := os.ReadFile(test.filePath) + require.NoError(t, err) + gitops, err := GitOpsFromBytes(dat, "./testdata") + require.NoError(t, err) + + if test.isTeam { + // Check team settings + assert.Equal(t, "Team1", *gitops.TeamName) + assert.Contains(t, gitops.TeamSettings, "webhook_settings") + assert.Contains(t, gitops.TeamSettings, "host_expiry_settings") + assert.Contains(t, gitops.TeamSettings, "features") + assert.Contains(t, gitops.TeamSettings, "secrets") + secrets, ok := gitops.TeamSettings["secrets"] + assert.True(t, ok, "secrets not found") + require.Len(t, secrets.([]*fleet.EnrollSecret), 2) + assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) + assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) + } else { + // Check org settings + 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.Contains(t, gitops.OrgSettings, "org_info") + assert.Contains(t, gitops.OrgSettings, "smtp_settings") + assert.Contains(t, gitops.OrgSettings, "sso_settings") + assert.Contains(t, gitops.OrgSettings, "integrations") + assert.Contains(t, gitops.OrgSettings, "mdm") + assert.Contains(t, gitops.OrgSettings, "webhook_settings") + assert.Contains(t, gitops.OrgSettings, "fleet_desktop") + assert.Contains(t, gitops.OrgSettings, "host_expiry_settings") + assert.Contains(t, gitops.OrgSettings, "features") + assert.Contains(t, gitops.OrgSettings, "vulnerability_settings") + assert.Contains(t, gitops.OrgSettings, "secrets") + secrets, ok := gitops.OrgSettings["secrets"] + assert.True(t, ok, "secrets not found") + require.Len(t, secrets.([]*fleet.EnrollSecret), 2) + assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) + assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) + } + + // Check controls + _, ok := gitops.Controls.MacOSSettings.(map[string]interface{}) + assert.True(t, ok, "macos_settings not found") + _, ok = gitops.Controls.WindowsSettings.(map[string]interface{}) + assert.True(t, ok, "windows_settings not found") + _, ok = gitops.Controls.EnableDiskEncryption.(bool) + assert.True(t, ok, "enable_disk_encryption not found") + _, ok = gitops.Controls.MacOSMigration.(map[string]interface{}) + assert.True(t, ok, "macos_migration not found") + _, ok = gitops.Controls.MacOSSetup.(map[string]interface{}) + assert.True(t, ok, "macos_setup not found") + _, ok = gitops.Controls.MacOSUpdates.(map[string]interface{}) + assert.True(t, ok, "macos_updates not found") + _, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool) + assert.True(t, ok, "windows_enabled_and_configured not found") + _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{}) + assert.True(t, ok, "windows_updates not found") + + // Check agent options + assert.NotNil(t, gitops.AgentOptions) + assert.Contains(t, string(*gitops.AgentOptions), "distributed_denylist_duration") + + // Check queries + require.Len(t, gitops.Queries, 3) + assert.Equal(t, "Scheduled query stats", gitops.Queries[0].Name) + assert.Equal(t, "orbit_info", gitops.Queries[1].Name) + assert.Equal(t, "osquery_info", gitops.Queries[2].Name) + + // Check policies + require.Len(t, gitops.Policies, 5) + assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name) + assert.Equal(t, "Passing policy", gitops.Policies[1].Name) + assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name) + assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name) + assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name) + + }, + ) + } +} + +func TestDuplicatePolicyNames(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"policies"}) + config += ` +policies: + - name: My policy + platform: linux + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: My policy + platform: windows + query: SELECT 1; +` + _, err := GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "duplicate policy names") +} + +func TestDuplicateQueryNames(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"queries"}) + config += ` +queries: +- name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +- name: orbit_info + query: SELECT 1; + interval: 300 + platform: windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +` + _, err := GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "duplicate query names") +} + +func TestUnicodeQueryNames(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"queries"}) + config += ` +queries: +- name: 😊 orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +` + _, err := GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "query name must be in ASCII") +} + +func TestUnicodeTeamName(t *testing.T) { + t.Parallel() + config := getTeamConfig([]string{"name"}) + config += `name: 😊 TeamName` + _, err := GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "team name must be in ASCII") +} + +func TestMixingGlobalAndTeamConfig(t *testing.T) { + t.Parallel() + + // Mixing org_settings and team name + config := getGlobalConfig(nil) + config += "name: TeamName\n" + _, err := GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'") + + // Mixing org_settings and team_settings + config = getGlobalConfig(nil) + config += "team_settings:\n secrets: []\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'") + + // Mixing org_settings and team name and team_settings + config = getGlobalConfig(nil) + config += "name: TeamName\n" + config += "team_settings:\n secrets: []\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'") + +} + +func TestInvalidGitOpsYaml(t *testing.T) { + t.Parallel() + + // Bad YAML + _, err := GitOpsFromBytes([]byte("bad:\nbad"), "") + assert.ErrorContains(t, err, "failed to unmarshal") + + for _, name := range []string{"global", "team"} { + t.Run( + name, func(t *testing.T) { + isTeam := name == "team" + getConfig := getGlobalConfig + if isTeam { + getConfig = getTeamConfig + } + + if isTeam { + // Invalid top level key + config := getConfig(nil) + config += "unknown_key:\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "unknown top-level field") + + // Invalid team name + config = getConfig([]string{"name"}) + config += "name: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal name") + + // Missing team name + config = getConfig([]string{"name"}) + config += "name:\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'name' is required") + + // Invalid team_settings + config = getConfig([]string{"team_settings"}) + config += "team_settings:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal team_settings") + + // Invalid team_settings in a separate file + tmpFile, err := os.CreateTemp(t.TempDir(), "*team_settings.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"team_settings"}) + config += fmt.Sprintf("%s:\n path: %s\n", "team_settings", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal team settings file") + + // Invalid secrets 1 + config = getConfig([]string{"team_settings"}) + config += "team_settings:\n secrets: bad\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "must be a list of secret items") + + // Invalid secrets 2 + config = getConfig([]string{"team_settings"}) + config += "team_settings:\n secrets: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "must have a 'secret' key") + + // Missing secrets + config = getConfig([]string{"team_settings"}) + config += "team_settings:\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'team_settings.secrets' is required") + } else { + // Invalid org_settings + config := getConfig([]string{"org_settings"}) + config += "org_settings:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal org_settings") + + // Invalid org_settings in a separate file + tmpFile, err := os.CreateTemp(t.TempDir(), "*org_settings.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"org_settings"}) + config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal org settings file") + + // Invalid secrets 1 + config = getConfig([]string{"org_settings"}) + config += "org_settings:\n secrets: bad\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "must be a list of secret items") + + // Invalid secrets 2 + config = getConfig([]string{"org_settings"}) + config += "org_settings:\n secrets: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "must have a 'secret' key") + + // Missing secrets + config = getConfig([]string{"org_settings"}) + config += "org_settings:\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "'org_settings.secrets' is required") + } + + // Invalid agent_options + config := getConfig([]string{"agent_options"}) + config += "agent_options:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal agent_options") + + // Invalid agent_options in a separate file + tmpFile, err := os.CreateTemp(t.TempDir(), "*agent_options.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"agent_options"}) + config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal agent options file") + + // Invalid controls + config = getConfig([]string{"controls"}) + config += "controls:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal controls") + + // Invalid controls in a separate file + tmpFile, err = os.CreateTemp(t.TempDir(), "*controls.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"controls"}) + config += fmt.Sprintf("%s:\n path: %s\n", "controls", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal controls file") + + // Invalid policies + config = getConfig([]string{"policies"}) + config += "policies:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal policies") + + // Invalid policies in a separate file + tmpFile, err = os.CreateTemp(t.TempDir(), "*policies.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"policies"}) + config += fmt.Sprintf("%s:\n - path: %s\n", "policies", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal policies file") + + // Policy name missing + config = getConfig([]string{"policies"}) + config += "policies:\n - query: SELECT 1;\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "name is required") + + // Policy query missing + config = getConfig([]string{"policies"}) + config += "policies:\n - name: Test Policy\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "query is required") + + // Invalid queries + config = getConfig([]string{"queries"}) + config += "queries:\n path: [2]\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal queries") + + // Invalid policies in a separate file + tmpFile, err = os.CreateTemp(t.TempDir(), "*queries.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString("[2]") + require.NoError(t, err) + config = getConfig([]string{"queries"}) + config += fmt.Sprintf("%s:\n - path: %s\n", "queries", tmpFile.Name()) + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal queries file") + + // Query name missing + config = getConfig([]string{"queries"}) + config += "queries:\n - query: SELECT 1;\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "name is required") + + // Query SQL query missing + config = getConfig([]string{"queries"}) + config += "queries:\n - name: Test Query\n" + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "query is required") + }, + ) + } +} + +func TestTopLevelGitOpsValidation(t *testing.T) { + t.Parallel() + tests := map[string]struct { + optsToExclude []string + shouldPass bool + isTeam bool + }{ + "all_present_global": { + optsToExclude: []string{}, + shouldPass: true, + }, + "all_present_team": { + optsToExclude: []string{}, + shouldPass: true, + isTeam: true, + }, + "missing_all": { + optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"}, + }, + "missing_controls": { + optsToExclude: []string{"controls"}, + }, + "missing_queries": { + optsToExclude: []string{"queries"}, + }, + "missing_policies": { + optsToExclude: []string{"policies"}, + }, + "missing_agent_options": { + optsToExclude: []string{"agent_options"}, + }, + "missing_org_settings": { + optsToExclude: []string{"org_settings"}, + }, + "missing_name": { + optsToExclude: []string{"name"}, + isTeam: true, + }, + "missing_team_settings": { + optsToExclude: []string{"team_settings"}, + isTeam: true, + }, + } + for name, test := range tests { + t.Run( + name, func(t *testing.T) { + var config string + if test.isTeam { + config = getTeamConfig(test.optsToExclude) + } else { + config = getGlobalConfig(test.optsToExclude) + } + _, err := GitOpsFromBytes([]byte(config), "") + if test.shouldPass { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, "is required") + } + }, + ) + } +} + +func TestGitOpsNullArrays(t *testing.T) { + t.Parallel() + + config := getGlobalConfig([]string{"queries", "policies"}) + config += "queries: null\npolicies: ~\n" + gitops, err := GitOpsFromBytes([]byte(config), "") + assert.NoError(t, err) + assert.Nil(t, gitops.Queries) + assert.Nil(t, gitops.Policies) +} + +func TestGitOpsPaths(t *testing.T) { + t.Parallel() + tests := map[string]struct { + isArray bool + isTeam bool + goodConfig string + }{ + "org_settings": { + isArray: false, + goodConfig: "secrets: []\n", + }, + "team_settings": { + isArray: false, + isTeam: true, + goodConfig: "secrets: []\n", + }, + "controls": { + isArray: false, + goodConfig: "windows_enabled_and_configured: true\n", + }, + "queries": { + isArray: true, + goodConfig: "[]", + }, + "policies": { + isArray: true, + goodConfig: "[]", + }, + "agent_options": { + isArray: false, + goodConfig: "name: value\n", + }, + } + + for name, test := range tests { + test := test + name := name + t.Run( + name, func(t *testing.T) { + t.Parallel() + + getConfig := getGlobalConfig + if test.isTeam { + getConfig = getTeamConfig + } + + // Test an absolute top level path + tmpFile, err := os.CreateTemp(t.TempDir(), "*good.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString(test.goodConfig) + require.NoError(t, err) + config := getConfig([]string{name}) + if test.isArray { + config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFile.Name()) + } else { + config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFile.Name()) + } + _, err = GitOpsFromBytes([]byte(config), "") + assert.NoError(t, err) + + // Test a relative top level path + config = getConfig([]string{name}) + dir, file := filepath.Split(tmpFile.Name()) + if test.isArray { + config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file) + } else { + config += fmt.Sprintf("%s:\n path: ./%s\n", name, file) + } + _, err = GitOpsFromBytes([]byte(config), dir) + assert.NoError(t, err) + + // Test a bad path + config = getConfig([]string{name}) + if test.isArray { + config += fmt.Sprintf("%s:\n - path: ./%s\n", name, "doesNotExist.yml") + } else { + config += fmt.Sprintf("%s:\n path: ./%s\n", name, "doesNotExist.yml") + } + _, err = GitOpsFromBytes([]byte(config), dir) + assert.ErrorContains(t, err, "no such file or directory") + + // Test a bad file -- cannot be unmarshalled + tmpFileBad, err := os.CreateTemp(t.TempDir(), "*invalid.yml") + require.NoError(t, err) + _, err = tmpFileBad.WriteString("bad:\nbad") + require.NoError(t, err) + config = getConfig([]string{name}) + if test.isArray { + config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFileBad.Name()) + } else { + config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFileBad.Name()) + } + _, err = GitOpsFromBytes([]byte(config), "") + assert.ErrorContains(t, err, "failed to unmarshal") + + // Test a nested path -- bad + tmpFileBad, err = os.CreateTemp(t.TempDir(), "*bad.yml") + require.NoError(t, err) + if test.isArray { + _, err = tmpFileBad.WriteString(fmt.Sprintf("- path: %s\n", tmpFile.Name())) + } else { + _, err = tmpFileBad.WriteString(fmt.Sprintf("path: %s\n", tmpFile.Name())) + } + require.NoError(t, err) + config = getConfig([]string{name}) + dir, file = filepath.Split(tmpFileBad.Name()) + if test.isArray { + config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file) + } else { + config += fmt.Sprintf("%s:\n path: ./%s\n", name, file) + } + _, err = GitOpsFromBytes([]byte(config), dir) + assert.ErrorContains(t, err, "nested paths are not supported") + }, + ) + } +} + +func getGlobalConfig(optsToExclude []string) string { + return getBaseConfig(topLevelOptions, optsToExclude) +} + +func getTeamConfig(optsToExclude []string) string { + return getBaseConfig(teamLevelOptions, optsToExclude) +} + +func getBaseConfig(options map[string]string, optsToExclude []string) string { + var config string + for key, value := range options { + if !slices.Contains(optsToExclude, key) { + config += value + "\n" + } + } + return config +} diff --git a/pkg/spec/testdata/agent-options.yml b/pkg/spec/testdata/agent-options.yml new file mode 100644 index 000000000..b2d24ce03 --- /dev/null +++ b/pkg/spec/testdata/agent-options.yml @@ -0,0 +1,14 @@ +command_line_flags: + distributed_denylist_duration: 0 +config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / diff --git a/pkg/spec/testdata/controls.yml b/pkg/spec/testdata/controls.yml new file mode 100644 index 000000000..2adff7403 --- /dev/null +++ b/pkg/spec/testdata/controls.yml @@ -0,0 +1,24 @@ +macos_settings: + custom_settings: + - path: ./lib/macos-password.mobileconfig +windows_settings: + custom_settings: + - path: ./lib/windows-screenlock.xml +scripts: + - path: ./lib/collect-fleetd-logs.sh +enable_disk_encryption: true +macos_migration: + enable: false + mode: "" + webhook_url: "" +macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null +macos_updates: + deadline: null + minimum_version: null +windows_enabled_and_configured: true +windows_updates: + deadline_days: null + grace_period_days: null diff --git a/pkg/spec/testdata/empty.yml b/pkg/spec/testdata/empty.yml new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/spec/testdata/global_config.yml b/pkg/spec/testdata/global_config.yml new file mode 100644 index 000000000..0f9f52486 --- /dev/null +++ b/pkg/spec/testdata/global_config.yml @@ -0,0 +1,27 @@ +# Test config +controls: # Controls added to "No team" + path: ./controls.yml +queries: + - path: ./top.queries.yml + - path: ./empty.yml + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - path: ./top.policies.yml + - path: ./top.policies2.yml + - path: ./empty.yml + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; +agent_options: + path: ./agent-options.yml +org_settings: + path: ./org-settings.yml diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml new file mode 100644 index 000000000..4c4ee3eb7 --- /dev/null +++ b/pkg/spec/testdata/global_config_no_paths.yml @@ -0,0 +1,181 @@ +# Test config +controls: # Controls added to "No team" + macos_settings: + custom_settings: + - path: ./lib/macos-password.mobileconfig + windows_settings: + custom_settings: + - path: ./lib/windows-screenlock.xml + scripts: + - path: ./lib/collect-fleetd-logs.sh + enable_disk_encryption: true + macos_migration: + enable: false + mode: "" + webhook_url: "" + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_enabled_and_configured: true + windows_updates: + deadline_days: null + grace_period_days: null +queries: + - name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot + - name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; + - name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true + - name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; +agent_options: + command_line_flags: + distributed_denylist_duration: 0 + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +org_settings: + server_settings: + debug_host_ids: + - 10728 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_reports_disabled: false + scripts_disabled: false + server_url: https://fleet.example.com + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: Fleet Device Management + smtp_settings: + authentication_method: authmethod_plain + authentication_type: authtype_username_password + configured: false + domain: "" + enable_smtp: false + enable_ssl_tls: true + enable_start_tls: true + password: "" + port: 587 + sender_address: "" + server: "" + user_name: "" + verify_ssl_certs: true + sso_settings: + enable_jit_provisioning: false + enable_jit_role_sync: false + enable_sso: true + enable_sso_idp_login: false + entity_id: https://saml.example.com/entityid + idp_image_url: "" + idp_name: MockSAML + issuer_uri: "" + metadata: "" + metadata_url: https://mocksaml.com/api/saml/metadata + integrations: + jira: + - api_token: JIRA_TOKEN + enable_failing_policies: true + enable_software_vulnerabilities: false + project_key: JIR + url: https://fleetdm.atlassian.net + username: reed@fleetdm.com + zendesk: [] + mdm: + apple_bm_default_team: "" + end_user_authentication: + entity_id: "" + idp_name: "" + issuer_uri: "" + metadata: "" + metadata_url: "" + webhook_settings: + failing_policies_webhook: + destination_url: https://host.docker.internal:8080/bozo + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: [] + host_status_webhook: + days_count: 0 + destination_url: "" + enable_host_status_webhook: false + host_percentage: 0 + interval: 24h0m0s + vulnerabilities_webhook: + destination_url: "" + enable_vulnerabilities_webhook: false + host_batch_size: 0 + fleet_desktop: # Applies to Fleet Premium only + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: # Applies to all teams + host_expiry_enabled: false + features: # Features added to all teams + enable_host_users: true + enable_software_inventory: true + vulnerability_settings: + databases_path: "" + secrets: # These secrets are used to enroll hosts to the "All teams" team + - secret: SampleSecret123 + - secret: ABC diff --git a/pkg/spec/testdata/org-settings.yml b/pkg/spec/testdata/org-settings.yml new file mode 100644 index 000000000..98038b207 --- /dev/null +++ b/pkg/spec/testdata/org-settings.yml @@ -0,0 +1,84 @@ +server_settings: + debug_host_ids: + - 10728 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_reports_disabled: false + scripts_disabled: false + server_url: https://fleet.example.com +org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: Fleet Device Management +smtp_settings: + authentication_method: authmethod_plain + authentication_type: authtype_username_password + configured: false + domain: "" + enable_smtp: false + enable_ssl_tls: true + enable_start_tls: true + password: "" + port: 587 + sender_address: "" + server: "" + user_name: "" + verify_ssl_certs: true +sso_settings: + enable_jit_provisioning: false + enable_jit_role_sync: false + enable_sso: true + enable_sso_idp_login: false + entity_id: https://saml.example.com/entityid + idp_image_url: "" + idp_name: MockSAML + issuer_uri: "" + metadata: "" + metadata_url: https://mocksaml.com/api/saml/metadata +integrations: + jira: + - api_token: JIRA_TOKEN + enable_failing_policies: true + enable_software_vulnerabilities: false + project_key: JIR + url: https://fleetdm.atlassian.net + username: reed@fleetdm.com + zendesk: [] +mdm: + apple_bm_default_team: "" + end_user_authentication: + entity_id: "" + idp_name: "" + issuer_uri: "" + metadata: "" + metadata_url: "" +webhook_settings: + failing_policies_webhook: + destination_url: https://host.docker.internal:8080/bozo + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: [] + host_status_webhook: + days_count: 0 + destination_url: "" + enable_host_status_webhook: false + host_percentage: 0 + interval: 24h0m0s + vulnerabilities_webhook: + destination_url: "" + enable_vulnerabilities_webhook: false + host_batch_size: 0 +fleet_desktop: # Applies to Fleet Premium only + transparency_url: https://fleetdm.com/transparency +host_expiry_settings: # Applies to all teams + host_expiry_enabled: false +features: # Features added to all teams + enable_host_users: true + enable_software_inventory: true +vulnerability_settings: + databases_path: "" +secrets: # These secrets are used to enroll hosts to the "All teams" team + - secret: SampleSecret123 + - secret: ABC diff --git a/pkg/spec/testdata/team-settings.yml b/pkg/spec/testdata/team-settings.yml new file mode 100644 index 000000000..620d16639 --- /dev/null +++ b/pkg/spec/testdata/team-settings.yml @@ -0,0 +1,14 @@ +secrets: + - secret: "SampleSecret123" + - secret: "ABC" +webhook_settings: + failing_policies_webhook: + enable_failing_policies_webhook: true + destination_url: https://example.tines.com/webhook + policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9] +features: + enable_host_users: true + enable_software_inventory: true +host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml new file mode 100644 index 000000000..9dbf7f0de --- /dev/null +++ b/pkg/spec/testdata/team_config.yml @@ -0,0 +1,27 @@ +name: Team1 +team_settings: + path: ./team-settings.yml +agent_options: + path: ./agent-options.yml +controls: + path: ./controls.yml +queries: + - path: ./top.queries.yml + - path: ./empty.yml + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - path: ./top.policies.yml + - path: ./top.policies2.yml + - path: ./empty.yml + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml new file mode 100644 index 000000000..207317e64 --- /dev/null +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -0,0 +1,111 @@ +name: Team1 +team_settings: + secrets: + - secret: "SampleSecret123" + - secret: "ABC" + webhook_settings: + failing_policies_webhook: + enable_failing_policies_webhook: true + destination_url: https://example.tines.com/webhook + policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9] + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: + command_line_flags: + distributed_denylist_duration: 0 + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +controls: + macos_settings: + custom_settings: + - path: ./lib/macos-password.mobileconfig + windows_settings: + custom_settings: + - path: ./lib/windows-screenlock.xml + scripts: + - path: ./lib/collect-fleetd-logs.sh + enable_disk_encryption: true + macos_setup: + bootstrap_package: null + enable_end_user_authentication: false + macos_setup_assistant: null + macos_updates: + deadline: null + minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null + macos_migration: + enable: false + mode: "" + webhook_url: "" + windows_enabled_and_configured: true +queries: + - name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot + - name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; + - name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true + - name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; diff --git a/pkg/spec/testdata/top.policies.yml b/pkg/spec/testdata/top.policies.yml new file mode 100644 index 000000000..46f6d5546 --- /dev/null +++ b/pkg/spec/testdata/top.policies.yml @@ -0,0 +1,16 @@ +- name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; +- name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; +- name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true diff --git a/pkg/spec/testdata/top.policies2.yml b/pkg/spec/testdata/top.policies2.yml new file mode 100644 index 000000000..d4cf4d179 --- /dev/null +++ b/pkg/spec/testdata/top.policies2.yml @@ -0,0 +1,5 @@ +- name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; diff --git a/pkg/spec/testdata/top.queries.yml b/pkg/spec/testdata/top.queries.yml new file mode 100644 index 000000000..988f52158 --- /dev/null +++ b/pkg/spec/testdata/top.queries.yml @@ -0,0 +1,19 @@ +- name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot +- name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index b642478e1..63466da2e 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -599,7 +599,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs checksum ) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s) ON DUPLICATE KEY UPDATE - name = VALUES(name), query = VALUES(query), description = VALUES(description), author_id = VALUES(author_id), @@ -610,12 +609,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs ) for _, spec := range specs { - // Validate that the team is not being changed - err := validatePolicyTeamChange(ctx, ds, spec) - if err != nil { - return err - } - res, err := tx.ExecContext(ctx, query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical, ) @@ -637,44 +630,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs }) } -// Changing the team of a policy is not allowed, so check if the policy exists -// and return an error if the team is changing -func validatePolicyTeamChange(ctx context.Context, ds *Datastore, spec *fleet.PolicySpec) error { - policy, err := ds.PolicyByName(ctx, spec.Name) - if err != nil { - if !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "Error fetching policy by name") - } - // If no rows found there is no policy to validate against - return nil - } - - // If the policy exists - if policy != nil { - // Check if the policy is global - if policy.TeamID == nil { - if spec.Team != "" { - return ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: fmt.Sprintf("cannot change the team of an existing global policy"), - }) - } - } else { - // If it's not global, fetch the team name and compare - team, err := ds.Team(ctx, *policy.TeamID) - if err != nil { - return ctxerr.Wrap(ctx, err, "Error fetching team by ID") - } - - if spec.Team != team.Name { - return ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: fmt.Sprintf("cannot change the team of an existing policy"), - }) - } - } - } - return nil -} - func amountPoliciesDB(ctx context.Context, db sqlx.QueryerContext) (int, error) { var amount int err := sqlx.GetContext(ctx, db, &amount, `SELECT count(*) FROM policies`) diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 5e2bcbcdc..d6b55aa22 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -1369,17 +1369,19 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { assert.Equal(t, "some other resolution updated", *teamPolicies[0].Resolution) assert.Equal(t, "windows", teamPolicies[0].Platform) - // Test error when modifying team on existing policy - require.Error(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ - { - Name: "query1", - Query: "select 1 from updated again;", - Description: "query1 desc updated again", - Resolution: "some resolution updated again", - Team: "team1", // Modifying teams on existing policies is not allowed - Platform: "", - }, - })) + // Creating the same policy for a different team is allowed. + require.NoError( + t, ds.ApplyPolicySpecs( + ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "query1", + Query: "select 1 from updated again;", + Description: "query1 desc updated again", + Resolution: "some resolution updated again", + Team: "team1", + Platform: "", + }, + })) } func testPoliciesSave(t *testing.T, ds *Datastore) { diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 092117d9b..78d57f86c 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -228,14 +228,18 @@ func (p PolicySpec) Verify() error { return nil } -// return first duplicate name of policies or empty string if no duplicates found +// FirstDuplicatePolicySpecName returns first duplicate name of policies (in a team) or empty string if no duplicates found func FirstDuplicatePolicySpecName(specs []*PolicySpec) string { - names := make(map[string]struct{}) + teams := make(map[string]map[string]struct{}) for _, spec := range specs { - if _, ok := names[spec.Name]; ok { - return spec.Name + if team, ok := teams[spec.Team]; ok { + if _, ok = team[spec.Name]; ok { + return spec.Name + } + team[spec.Name] = struct{}{} + } else { + teams[spec.Team] = map[string]struct{}{spec.Name: {}} } - names[spec.Name] = struct{}{} } return "" } diff --git a/server/fleet/service.go b/server/fleet/service.go index fdfaaa946..6753907b3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -881,7 +881,10 @@ type Service interface { // BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified // team or for hosts with no team. - BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool) error + BatchSetMDMProfiles( + ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool, + assumeEnabled bool, + ) error /////////////////////////////////////////////////////////////////////////////// // Common MDM diff --git a/server/service/client.go b/server/service/client.go index e03af0a54..cd991f912 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -322,7 +322,7 @@ func (c *Client) ApplyGroup( baseDir string, logf func(format string, args ...interface{}), opts fleet.ApplySpecOptions, -) error { +) (map[string]uint, error) { logfn := func(format string, args ...interface{}) { if logf != nil { logf(format, args...) @@ -335,7 +335,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyQueries(specs.Queries); err != nil { - return fmt.Errorf("applying queries: %w", err) + return nil, fmt.Errorf("applying queries: %w", err) } logfn("[+] applied %d queries\n", len(specs.Queries)) } @@ -346,7 +346,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyLabels(specs.Labels); err != nil { - return fmt.Errorf("applying labels: %w", err) + return nil, fmt.Errorf("applying labels: %w", err) } logfn("[+] applied %d labels\n", len(specs.Labels)) } @@ -358,7 +358,9 @@ func (c *Client) ApplyGroup( } else { // Policy names must be unique, return error if duplicate policy names are found if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { - return fmt.Errorf("applying policies: policy names must be globally unique. Please correct policy %q and try again.", policyName) + return nil, fmt.Errorf( + "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, + ) } // If set, override the team in all the policies. @@ -368,7 +370,7 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyPolicies(specs.Policies); err != nil { - return fmt.Errorf("applying policies: %w", err) + return nil, fmt.Errorf("applying policies: %w", err) } logfn("[+] applied %d policies\n", len(specs.Policies)) } @@ -379,7 +381,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyPacks(specs.Packs); err != nil { - return fmt.Errorf("applying packs: %w", err) + return nil, fmt.Errorf("applying packs: %w", err) } logfn("[+] applied %d packs\n", len(specs.Packs)) } @@ -400,33 +402,44 @@ func (c *Client) ApplyGroup( if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(allCustomSettings) > 0 { fileContents, err := getProfilesContents(baseDir, allCustomSettings) if err != nil { - return err + return nil, err } - if err := c.ApplyNoTeamProfiles(fileContents, opts); err != nil { - return fmt.Errorf("applying custom settings: %w", err) + // Figure out if MDM should be enabled. + assumeEnabled := false + // This cast is safe because we've already checked AppConfig when extracting custom settings + mdmConfigMap, ok := specs.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{}) + if ok { + mdmEnabled, ok := mdmConfigMap["windows_enabled_and_configured"] + if ok { + assumeEnabled, ok = mdmEnabled.(bool) + assumeEnabled = ok && assumeEnabled + } + } + if err := c.ApplyNoTeamProfiles(fileContents, opts, assumeEnabled); err != nil { + return nil, fmt.Errorf("applying custom settings: %w", err) } } if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil { if macosSetup.BootstrapPackage.Value != "" { pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value) if err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } } } if macosSetup.MacOSSetupAssistant.Value != "" { content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value)) if err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } } } @@ -437,7 +450,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -445,11 +458,11 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamScripts(scriptPayloads, opts); err != nil { - return fmt.Errorf("applying custom settings: %w", err) + return nil, fmt.Errorf("applying custom settings: %w", err) } } if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } if opts.DryRun { logfn("[+] would've applied fleet config\n") @@ -460,15 +473,16 @@ func (c *Client) ApplyGroup( if specs.EnrollSecret != nil { if opts.DryRun { - logfn("[!] ignoring enroll secrets, dry run mode only supported for 'config' and 'team' specs\n") + logfn("[+] would've applied enroll secrets\n") } else { if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret); err != nil { - return fmt.Errorf("applying enroll secrets: %w", err) + return nil, fmt.Errorf("applying enroll secrets: %w", err) } logfn("[+] applied enroll secrets\n") } } + var teamIDsByName map[string]uint if len(specs.Teams) > 0 { // extract the teams' custom settings and resolve the files immediately, so // that any non-existing file error is found before applying the specs. @@ -478,7 +492,7 @@ func (c *Client) ApplyGroup( for k, paths := range tmMDMSettings { fileContents, err := getProfilesContents(baseDir, paths) if err != nil { - return err + return nil, err } tmFileContents[k] = fileContents } @@ -490,14 +504,14 @@ func (c *Client) ApplyGroup( if setup.BootstrapPackage.Value != "" { bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value) if err != nil { - return fmt.Errorf("applying teams: %w", err) + return nil, fmt.Errorf("applying teams: %w", err) } tmBootstrapPackages[k] = bp } if setup.MacOSSetupAssistant.Value != "" { b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value)) if err != nil { - return fmt.Errorf("applying teams: %w", err) + return nil, fmt.Errorf("applying teams: %w", err) } tmMacSetupAssistants[k] = b } @@ -511,7 +525,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return fmt.Errorf("applying fleet config: %w", err) + return nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -523,15 +537,22 @@ func (c *Client) ApplyGroup( // Next, apply the teams specs before saving the profiles, so that any // non-existing team gets created. - teamIDsByName, err := c.ApplyTeams(specs.Teams, opts) + var err error + teamIDsByName, err = c.ApplyTeams(specs.Teams, opts) if err != nil { - return fmt.Errorf("applying teams: %w", err) + return nil, fmt.Errorf("applying teams: %w", err) } if len(tmFileContents) > 0 { for tmName, profs := range tmFileContents { - if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil { - return fmt.Errorf("applying custom settings for team %q: %w", tmName, err) + teamID, ok := teamIDsByName[tmName] + if opts.DryRun && (teamID == 0 || !ok) { + logfn("[+] would've applied MDM profiles for new team %s\n", tmName) + } else { + logfn("[+] applying MDM profiles for team %s\n", tmName) + if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil { + return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) + } } } } @@ -539,12 +560,12 @@ func (c *Client) ApplyGroup( for tmName, tmID := range teamIDsByName { if bp, ok := tmBootstrapPackages[tmName]; ok { if err := c.EnsureBootstrapPackage(bp, tmID); err != nil { - return fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) + return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) } } if b, ok := tmMacSetupAssistants[tmName]; ok { if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil { - return fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) + return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) } } } @@ -552,7 +573,7 @@ func (c *Client) ApplyGroup( if len(tmScriptsPayloads) > 0 { for tmName, scripts := range tmScriptsPayloads { if err := c.ApplyTeamScripts(tmName, scripts, opts); err != nil { - return fmt.Errorf("applying scripts for team %q: %w", tmName, err) + return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) } } } @@ -568,12 +589,12 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil { - return fmt.Errorf("applying user roles: %w", err) + return nil, fmt.Errorf("applying user roles: %w", err) } logfn("[+] applied user roles\n") } } - return nil + return teamIDsByName, nil } func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup { @@ -832,3 +853,179 @@ func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOS } return m } + +// DoGitOps applies the GitOps config to Fleet. +func (c *Client) DoGitOps( + ctx context.Context, + config *spec.GitOps, + baseDir string, + logf func(format string, args ...interface{}), + dryRun bool, +) error { + var err error + logFn := func(format string, args ...interface{}) { + if logf != nil { + logf(format, args...) + } + } + group := spec.Group{} + scripts := make([]interface{}, len(config.Controls.Scripts)) + for i, script := range config.Controls.Scripts { + scripts[i] = *script.Path + } + var mdmAppConfig map[string]interface{} + var team map[string]interface{} + if config.TeamName == nil { + group.AppConfig = config.OrgSettings + group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)} + group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions + delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup + if _, ok := group.AppConfig.(map[string]interface{})["mdm"]; !ok { + group.AppConfig.(map[string]interface{})["mdm"] = map[string]interface{}{} + } + mdmAppConfig = group.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{}) + mdmAppConfig["macos_migration"] = config.Controls.MacOSMigration + mdmAppConfig["windows_enabled_and_configured"] = config.Controls.WindowsEnabledAndConfigured + group.AppConfig.(map[string]interface{})["scripts"] = scripts + } else { + team = make(map[string]interface{}) + team["name"] = *config.TeamName + team["agent_options"] = config.AgentOptions + if hostExpirySettings, ok := config.TeamSettings["host_expiry_settings"]; ok { + team["host_expiry_settings"] = hostExpirySettings + } + if features, ok := config.TeamSettings["features"]; ok { + team["features"] = features + } + team["scripts"] = scripts + team["secrets"] = config.TeamSettings["secrets"] + team["mdm"] = map[string]interface{}{} + mdmAppConfig = team["mdm"].(map[string]interface{}) + } + // Common controls settings between org and team settings + mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings + mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates + mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup + mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates + mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings + mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption + if config.TeamName != nil { + rawTeam, err := json.Marshal(team) + if err != nil { + return fmt.Errorf("error marshalling team spec: %w", err) + } + group.Teams = []json.RawMessage{rawTeam} + } + + // Apply org settings, scripts, enroll secrets, and controls + teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplySpecOptions{DryRun: dryRun}) + if err != nil { + return err + } + if config.TeamName != nil { + teamID, ok := teamIDsByName[*config.TeamName] + if !ok || teamID == 0 { + if dryRun { + logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName) + return nil + } + return fmt.Errorf("team %s not created", *config.TeamName) + } + config.TeamID = &teamID + } + + err = c.doGitOpsPolicies(config, logFn, dryRun) + if err != nil { + return err + } + err = c.doGitOpsQueries(config, logFn, dryRun) + if err != nil { + return err + } + + return nil +} + +func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { + // Get the ids and names of current policies to figure out which ones to delete + policies, err := c.GetPolicies(config.TeamID) + if err != nil { + return fmt.Errorf("error getting current policies: %w", err) + } + if len(config.Policies) > 0 { + numPolicies := len(config.Policies) + logFn("[+] syncing %d policies\n", numPolicies) + if !dryRun { + // Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps. + if err := c.ApplyPolicies(config.Policies); err != nil { + return fmt.Errorf("error applying policies: %w", err) + } + logFn("[+] synced %d policies\n", numPolicies) + } + } + var policiesToDelete []uint + for _, oldItem := range policies { + found := false + for _, newItem := range config.Policies { + if oldItem.Name == newItem.Name { + found = true + break + } + } + if !found { + policiesToDelete = append(policiesToDelete, oldItem.ID) + fmt.Printf("[-] deleting policy %s\n", oldItem.Name) + } + } + if len(policiesToDelete) > 0 { + logFn("[-] deleting %d policies\n", len(policiesToDelete)) + if !dryRun { + if err := c.DeletePolicies(config.TeamID, policiesToDelete); err != nil { + return fmt.Errorf("error deleting policies: %w", err) + } + } + } + return nil +} + +func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { + // Get the ids and names of current queries to figure out which ones to delete + queries, err := c.GetQueries(config.TeamID, nil) + if err != nil { + return fmt.Errorf("error getting current queries: %w", err) + } + if len(config.Queries) > 0 { + numQueries := len(config.Queries) + logFn("[+] syncing %d queries\n", numQueries) + if !dryRun { + // Note: We are reusing the spec flow here for adding/updating queries, instead of creating a new flow for GitOps. + if err := c.ApplyQueries(config.Queries); err != nil { + return fmt.Errorf("error applying queries: %w", err) + } + logFn("[+] synced %d queries\n", numQueries) + } + } + var queriesToDelete []uint + for _, oldQuery := range queries { + found := false + for _, newQuery := range config.Queries { + if oldQuery.Name == newQuery.Name { + found = true + break + } + } + if !found { + queriesToDelete = append(queriesToDelete, oldQuery.ID) + fmt.Printf("[-] deleting query %s\n", oldQuery.Name) + } + } + if len(queriesToDelete) > 0 { + logFn("[-] deleting %d queries\n", len(queriesToDelete)) + if !dryRun { + if err := c.DeleteQueries(queriesToDelete); err != nil { + return fmt.Errorf("error deleting queries: %w", err) + } + } + } + return nil +} diff --git a/server/service/client_appconfig.go b/server/service/client_appconfig.go index 75e6e91c6..52894caa0 100644 --- a/server/service/client_appconfig.go +++ b/server/service/client_appconfig.go @@ -14,9 +14,16 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions // ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts // in no team. -func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error { +func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error { verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch" - return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery()) + query := opts.RawQuery() + if assumeEnabled { + if query != "" { + query += "&" + } + query += "assume_enabled=true" + } + return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query) } // GetAppConfig fetches the application config from the server API diff --git a/server/service/client_policies.go b/server/service/client_policies.go index 78efe8b77..a8425bebf 100644 --- a/server/service/client_policies.go +++ b/server/service/client_policies.go @@ -1,5 +1,10 @@ package service +import ( + "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" +) + func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platform string) error { req := globalPolicyRequest{ Name: name, @@ -12,3 +17,44 @@ func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platfo var responseBody globalPolicyResponse return c.authenticatedRequest(req, verb, path, &responseBody) } + +// ApplyPolicies sends the list of Policies to be applied to the +// Fleet instance. +func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error { + req := applyPolicySpecsRequest{Specs: specs} + verb, path := "POST", "/api/latest/fleet/spec/policies" + var responseBody applyPolicySpecsResponse + return c.authenticatedRequest(req, verb, path, &responseBody) +} + +// GetPolicies retrieves the list of Policies. Inherited policies are excluded. +func (c *Client) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { + verb, path := "GET", "" + if teamID != nil { + path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies", *teamID) + } else { + path = "/api/latest/fleet/policies" + } + // The response body also works for listTeamPoliciesResponse because they contain some of the same members. + var responseBody listGlobalPoliciesResponse + err := c.authenticatedRequest(nil, verb, path, &responseBody) + if err != nil { + return nil, err + } + return responseBody.Policies, nil +} + +// DeletePolicies deletes several policies. +func (c *Client) DeletePolicies(teamID *uint, IDs []uint) error { + verb, path := "POST", "" + req := deleteTeamPoliciesRequest{IDs: IDs} + if teamID != nil { + path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", *teamID) + req.TeamID = *teamID + } else { + path = "/api/latest/fleet/policies/delete" + } + // The response body also works for deleteTeamPoliciesResponse because they contain some of the same members. + var responseBody deleteGlobalPoliciesResponse + return c.authenticatedRequest(req, verb, path, &responseBody) +} diff --git a/server/service/client_queries.go b/server/service/client_queries.go index 0d0f8ce2f..146cac01a 100644 --- a/server/service/client_queries.go +++ b/server/service/client_queries.go @@ -52,3 +52,11 @@ func (c *Client) DeleteQuery(name string) error { var responseBody deleteQueryResponse return c.authenticatedRequest(nil, verb, path, &responseBody) } + +// DeleteQueries deletes several queries. +func (c *Client) DeleteQueries(IDs []uint) error { + req := deleteQueriesRequest{IDs: IDs} + verb, path := "POST", "/api/latest/fleet/queries/delete" + var responseBody deleteQueriesResponse + return c.authenticatedRequest(req, verb, path, &responseBody) +} diff --git a/server/service/client_teams.go b/server/service/client_teams.go index d11e972ca..fa59b4178 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -74,15 +74,6 @@ func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBat return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query.Encode()) } -// ApplyPolicies sends the list of Policies to be applied to the -// Fleet instance. -func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error { - req := applyPolicySpecsRequest{Specs: specs} - verb, path := "POST", "/api/latest/fleet/spec/policies" - var responseBody applyPolicySpecsResponse - return c.authenticatedRequest(req, verb, path, &responseBody) -} - // ApplyTeamScripts sends the list of scripts to be applied for the specified // team. func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 6428a26dd..5dc6a2295 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -317,8 +317,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { } applyResp = applyTeamSpecsResponse{} s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true") - // dry-run never returns id to name mappings as it may not have them - require.Empty(t, applyResp.TeamIDsByName) + assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName) // dry-run with macos disk encryption set to true teamSpecs = map[string]any{ diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 947c65160..01c87bfd7 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -10647,7 +10647,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() { s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` { "windows_settings": { "custom_settings": null } } `), http.StatusOK, &applyResp, "dry_run", "true") - require.Len(t, applyResp.TeamIDsByName, 0) + assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) diff --git a/server/service/mdm.go b/server/service/mdm.go index aa2bccef2..3f1bf6aab 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1393,10 +1393,11 @@ func (svc *Service) validateProfileLabels(ctx context.Context, labelNames []stri //////////////////////////////////////////////////////////////////////////////// type batchSetMDMProfilesRequest struct { - TeamID *uint `json:"-" query:"team_id,optional"` - TeamName *string `json:"-" query:"team_name,optional"` - DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes - Profiles backwardsCompatProfilesParam `json:"profiles"` + TeamID *uint `json:"-" query:"team_id,optional"` + TeamName *string `json:"-" query:"team_name,optional"` + DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes + AssumeEnabled bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled + Profiles backwardsCompatProfilesParam `json:"profiles"` } type backwardsCompatProfilesParam []fleet.MDMProfileBatchPayload @@ -1439,13 +1440,16 @@ func (r batchSetMDMProfilesResponse) Status() int { return http.StatusNoContent func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*batchSetMDMProfilesRequest) - if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil { + if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false, req.AssumeEnabled); err != nil { return batchSetMDMProfilesResponse{Err: err}, nil } return batchSetMDMProfilesResponse{}, nil } -func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool) error { +func (svc *Service) BatchSetMDMProfiles( + ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool, + assumeEnabled bool, +) error { var err error if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil { return err @@ -1455,6 +1459,9 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName if err != nil { return ctxerr.Wrap(ctx, err, "getting app config") } + if assumeEnabled { + appCfg.MDM.WindowsEnabledAndConfigured = true + } if err := validateProfiles(profiles); err != nil { return ctxerr.Wrap(ctx, err, "validating profiles") diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 707fa83fb..06b8e1385 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1389,7 +1389,7 @@ func TestMDMBatchSetProfiles(t *testing.T) { } ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier}) - err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false) + err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false, false) if tt.wantErr == "" { require.NoError(t, err) require.True(t, ds.BatchSetMDMProfilesFuncInvoked) diff --git a/server/service/scripts.go b/server/service/scripts.go index 8ada62250..7924140f0 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -759,6 +759,10 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT if maybeTmID != nil || maybeTmName != nil { team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName) if err != nil { + // If this is a dry run, the team may not have been created yet + if dryRun && fleet.IsNotFound(err) { + return nil + } return err } teamID = &team.ID