Fix macOS AUTO_UPDATE when updates are disabled

The check read only AutomaticCheckEnabled from /Library/Preferences,
falling back to softwareupdate --schedule when that key was absent.
Both describe automatic checking alone, so a Mac with downloads or
installs turned off still reported PASS. The key is also absent when
a configuration profile manages it, since the value then lives in
/Library/Managed Preferences, a layer never consulted.

Read the five Software Update preferences backing the System Settings
toggles, resolving each from the managed layer before the system one.
macOS treats them as enabled when unset, so only an explicit disabled
value fails. The now-unused softwareupdate binary leaves the command
allowlist.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-24 22:12:32 +02:00
parent 2532b0bb6b
commit af7f44c0c1
2 changed files with 140 additions and 41 deletions

View File

@@ -376,54 +376,154 @@ func darwinOSVersion(ctx context.Context) Result {
return pass(ev)
}
const (
darwinSoftwareUpdateManagedDomain = "/Library/Managed Preferences/com.apple.SoftwareUpdate"
darwinSoftwareUpdateSystemDomain = "/Library/Preferences/com.apple.SoftwareUpdate"
darwinPrefSourceManaged = "managed"
darwinPrefSourceSystem = "system"
darwinPrefSourceDefault = "default"
)
// darwinSoftwareUpdateKeys must all stay enabled for AUTO_UPDATE to pass. They
// map to the System Settings toggles for checking, downloading and installing
// macOS updates, security responses and system data files. App Store app
// updates are out of scope, matching the Windows and Linux implementations.
var darwinSoftwareUpdateKeys = []string{
"AutomaticCheckEnabled",
"AutomaticDownload",
"AutomaticallyInstallMacOSUpdates",
"CriticalUpdateInstall",
"ConfigDataInstall",
}
// darwinSoftwareUpdatePref is one Software Update preference resolved across
// the CFPreferences layers. An empty value with source default means macOS
// falls back to its own default, which is enabled for every key we read.
type darwinSoftwareUpdatePref struct {
value string
source string
err string
stderr string
}
func darwinAutoUpdate(ctx context.Context) Result {
primary := RunCommand(
prefs := make(map[string]darwinSoftwareUpdatePref, len(darwinSoftwareUpdateKeys))
for _, key := range darwinSoftwareUpdateKeys {
prefs[key] = darwinReadSoftwareUpdatePref(ctx, key)
}
return darwinSoftwareUpdateResult(prefs)
}
// darwinReadSoftwareUpdatePref resolves one key with MDM precedence: a value
// forced by a configuration profile wins over the local system preference
// because the user cannot override it.
func darwinReadSoftwareUpdatePref(ctx context.Context, key string) darwinSoftwareUpdatePref {
managed := RunCommand(
ctx,
"defaults",
"read",
"/Library/Preferences/com.apple.SoftwareUpdate",
"AutomaticCheckEnabled",
darwinSoftwareUpdateManagedDomain,
key,
)
if primary.Err == nil {
ev := map[string]any{
"backend": "defaults",
"automatic_check_enabled": primary.Stdout,
if managed.Err == nil {
return darwinSoftwareUpdatePref{
value: strings.TrimSpace(managed.Stdout),
source: darwinPrefSourceManaged,
}
if strings.TrimSpace(primary.Stdout) == "1" {
return pass(ev)
}
if !darwinDefaultsMissing(managed) {
return darwinSoftwareUpdatePref{
source: darwinPrefSourceManaged,
err: errString(managed.Err),
stderr: managed.Stderr,
}
}
system := RunCommand(
ctx,
"defaults",
"read",
darwinSoftwareUpdateSystemDomain,
key,
)
if system.Err == nil {
return darwinSoftwareUpdatePref{
value: strings.TrimSpace(system.Stdout),
source: darwinPrefSourceSystem,
}
}
if darwinDefaultsMissing(system) {
return darwinSoftwareUpdatePref{source: darwinPrefSourceDefault}
}
return darwinSoftwareUpdatePref{
source: darwinPrefSourceSystem,
err: errString(system.Err),
stderr: system.Stderr,
}
}
func darwinSoftwareUpdateResult(prefs map[string]darwinSoftwareUpdatePref) Result {
ev := map[string]any{"backend": "defaults"}
var disabled, indeterminate []string
for _, key := range darwinSoftwareUpdateKeys {
pref := prefs[key]
entry := map[string]any{"source": pref.source}
if pref.value != "" {
entry["value"] = pref.value
}
switch {
case pref.err != "":
entry["error"] = pref.err
if pref.stderr != "" {
entry["stderr"] = truncate(pref.stderr, 256)
}
indeterminate = append(indeterminate, key)
case pref.source == darwinPrefSourceDefault:
entry["enabled"] = true
default:
enabled := darwinPrefIndicatesEnabled(pref.value)
entry["enabled"] = enabled
if !enabled {
disabled = append(disabled, key)
}
}
ev[key] = entry
}
if len(disabled) > 0 {
ev["disabled_keys"] = disabled
return fail(ev)
}
fallback := RunCommand(ctx, "softwareupdate", "--schedule")
ev := map[string]any{
"backend": "softwareupdate",
"raw": fallback.Stdout,
"defaults_error": errString(primary.Err),
"defaults_stderr": primary.Stderr,
}
if fallback.Err != nil ||
needsAdmin(fallback.Stdout) ||
needsAdmin(fallback.Stderr) {
ev["error"] = errString(fallback.Err)
ev["stderr"] = fallback.Stderr
if len(indeterminate) > 0 {
ev["indeterminate_keys"] = indeterminate
return unknown(ev)
}
lower := strings.ToLower(fallback.Stdout)
switch {
case strings.Contains(lower, "is turned on"),
strings.Contains(lower, "automatic check is on"):
return pass(ev)
case strings.Contains(lower, "is turned off"),
strings.Contains(lower, "automatic check is off"):
return fail(ev)
return pass(ev)
}
func darwinPrefIndicatesEnabled(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes":
return true
}
return unknown(ev)
return false
}
func darwinPasswordPolicy(ctx context.Context) Result {

View File

@@ -21,16 +21,15 @@
package checks
var darwinCommandPaths = map[string][]string{
"defaults": {"/usr/bin/defaults"},
"fdesetup": {"/usr/bin/fdesetup"},
"osascript": {"/usr/bin/osascript"},
"pwpolicy": {"/usr/bin/pwpolicy"},
"softwareupdate": {"/usr/sbin/softwareupdate"},
"stat": {"/usr/bin/stat"},
"sudo": {"/usr/bin/sudo"},
"sw_vers": {"/usr/bin/sw_vers"},
"sysadminctl": {"/usr/sbin/sysadminctl"},
"systemsetup": {"/usr/sbin/systemsetup"},
"defaults": {"/usr/bin/defaults"},
"fdesetup": {"/usr/bin/fdesetup"},
"osascript": {"/usr/bin/osascript"},
"pwpolicy": {"/usr/bin/pwpolicy"},
"stat": {"/usr/bin/stat"},
"sudo": {"/usr/bin/sudo"},
"sw_vers": {"/usr/bin/sw_vers"},
"sysadminctl": {"/usr/sbin/sysadminctl"},
"systemsetup": {"/usr/sbin/systemsetup"},
}
func commandCandidates(cmd string) []string {