Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions pkg/spec/gitops.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package spec

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Comment thread
MagnusHJensen marked this conversation as resolved.
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
Expand Down
90 changes: 90 additions & 0 deletions pkg/spec/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<Replace><Item><Target><LocURI>./Vendor/MSFT/Policy/Config/Update/x</LocURI></Target></Item></Replace>`

Comment thread
MagnusHJensen marked this conversation as resolved.
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("<plist></plist>"), 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()

Expand Down
Loading