diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index d4c5a889762..afb2dca4021 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -1,6 +1,7 @@ package spec import ( + "bytes" "encoding/json" "errors" "fmt" @@ -16,6 +17,8 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/ghodss/yaml" "github.com/hashicorp/go-multierror" @@ -1206,9 +1209,86 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y result.Controls.AndroidSettings = androidSettings } + if err := validateOSUpdatesProfileConflict(result.Controls); err != nil { + multiError = multierror.Append(multiError, err) + } + return multiError } +// validateOSUpdatesProfileConflict rejects a config that both configures managed +// OS updates and includes a custom configuration profile that enforces OS +// updates. The server checks it for non-dry runs; we replicate it here against +// the incoming payload so dry-runs fail too. +func validateOSUpdatesProfileConflict(controls GitOpsControls) error { + macOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.MacOSUpdates) + iOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.IOSUpdates) + iPadOSConfigured := osUpdatesConfigured[fleet.AppleOSUpdateSettings](controls.IPadOSUpdates) + if macOSConfigured || iOSConfigured || iPadOSConfigured { + macOSSettings, _ := controls.MacOSSettings.(fleet.MacOSSettings) + for _, profile := range macOSSettings.CustomSettings { + contains, err := profileFileContains(profile.Path, apple_mdm.DeclarationTypeSoftwareUpdate) + if err != nil { + return err + } + if contains { + return fmt.Errorf("controls.apple_settings.configuration_profiles[]: %s", fleet.OSUpdatesAlreadyConfiguredErrorMessage) + } + } + } + + windowsConfigured := osUpdatesConfigured[fleet.WindowsUpdates](controls.WindowsUpdates) + if windowsConfigured { + windowsSettings, _ := controls.WindowsSettings.(fleet.WindowsSettings) + for _, profile := range windowsSettings.CustomSettings.Value { + contains, err := profileFileContains(profile.Path, syncml.FleetOSUpdateTargetLocURI) + if err != nil { + return err + } + if contains { + return fmt.Errorf("controls.windows_settings.configuration_profiles[]: %s", fleet.OSUpdatesAlreadyConfiguredErrorMessage) + } + } + } + + return nil +} + +// osUpdatesSettings is implemented by the OS update settings types that report +// whether managed OS updates are configured. +type osUpdatesSettings interface { + Configured() bool +} + +// osUpdatesConfigured unmarshals the raw controls value (e.g. controls.macos_updates) +// into T and reports whether it configures managed OS updates. +func osUpdatesConfigured[T osUpdatesSettings](v any) bool { + if v == nil { + return false + } + data, err := json.Marshal(v) + if err != nil { + return false + } + var settings T + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + return settings.Configured() +} + +// profileFileContains reports whether the profile file at path contains needle. +func profileFileContains(path, needle string) (bool, error) { + if path == "" { + return false, nil + } + fileBytes, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("failed to read profile file %s: %v", path, err) + } + return bytes.Contains(fileBytes, []byte(needle)), nil +} + func processControlsPathIfNeeded(controlsTop GitOpsControls, result *GitOps, controlsFilePath *string) []error { if controlsTop.Path == nil { result.Controls = controlsTop diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 75d7df70911..13f1dffa094 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -2952,6 +2952,96 @@ func TestGitOpsGlobProfiles(t *testing.T) { }) } +func TestGitOpsOSUpdatesProfileConflict(t *testing.T) { + t.Parallel() + + const appleDecl = `{"Type":"com.apple.configuration.softwareupdate.enforcement.specific","Identifier":"x","Payload":{}}` + const windowsProfile = `./Vendor/MSFT/Policy/Config/Update/x` + + t.Run("macos updates configured with software update declaration fails", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644)) + + config := getGlobalConfig([]string{"controls"}) + config += `controls: + macos_updates: + minimum_version: "14.0" + deadline: "2024-01-01" + apple_settings: + configuration_profiles: + - path: su.json +` + yamlPath := filepath.Join(dir, "gitops.yml") + require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) + + _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) + require.Error(t, err) + assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage) + }) + + t.Run("windows updates configured with software update profile fails", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "su.xml"), []byte(windowsProfile), 0o644)) + + config := getGlobalConfig([]string{"controls"}) + config += `controls: + windows_updates: + deadline_days: 5 + grace_period_days: 1 + windows_settings: + configuration_profiles: + - path: su.xml +` + yamlPath := filepath.Join(dir, "gitops.yml") + require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) + + _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) + require.Error(t, err) + assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage) + }) + + t.Run("software update declaration without configured os updates is allowed", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644)) + + config := getGlobalConfig([]string{"controls"}) + config += `controls: + apple_settings: + configuration_profiles: + - path: su.json +` + yamlPath := filepath.Join(dir, "gitops.yml") + require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) + + _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) + require.NoError(t, err) + }) + + t.Run("macos updates configured with non-conflicting profile is allowed", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.mobileconfig"), []byte(""), 0o644)) + + config := getGlobalConfig([]string{"controls"}) + config += `controls: + macos_updates: + minimum_version: "14.0" + deadline: "2024-01-01" + apple_settings: + configuration_profiles: + - path: plain.mobileconfig +` + yamlPath := filepath.Join(dir, "gitops.yml") + require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) + + _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) + require.NoError(t, err) + }) +} + func TestUnknownKeyDetection(t *testing.T) { t.Parallel()