Files
probo/pkg/deviceagent/checks/checks_darwin.go
Ludovic Vielle af7f44c0c1 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>
2026-07-24 22:16:18 +02:00

609 lines
15 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package checks
import (
"context"
"os"
"strconv"
"strings"
)
func init() {
Register(KeyDiskEncryption, darwinDiskEncryption)
Register(KeyScreenLock, darwinScreenLock)
Register(KeyFirewallEnabled, darwinFirewall)
Register(KeyTimeSync, darwinTimeSync)
Register(KeyOSVersion, darwinOSVersion)
Register(KeyAutoUpdate, darwinAutoUpdate)
Register(KeyPasswordPolicy, darwinPasswordPolicy)
Register(KeyRemoteLogin, darwinRemoteLogin)
Register(KeyMalwareProtection, darwinMalwareProtection)
}
func darwinDiskEncryption(ctx context.Context) Result {
out := RunCommand(ctx, "fdesetup", "status")
if out.Err != nil {
return unknown(
map[string]any{
"error": out.Err.Error(),
"stderr": out.Stderr,
},
)
}
on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on")
ev := map[string]any{"raw": out.Stdout}
if on {
return pass(ev)
}
return fail(ev)
}
func darwinScreenLock(ctx context.Context) Result {
if CommandExists("sysadminctl") {
status := RunCommand(ctx, "sysadminctl", "-screenLock", "status", "-password", "-")
rawCombined := strings.TrimSpace(status.Stdout + "\n" + status.Stderr)
ev := map[string]any{
"backend": "sysadminctl",
"raw": rawCombined,
"raw_stdout": status.Stdout,
"raw_stderr": status.Stderr,
}
mode, seconds, ok := darwinScreenLockMode(rawCombined)
if ok {
ev["mode"] = mode
if mode == "seconds" && seconds >= 0 {
ev["seconds"] = seconds
}
if mode == "immediate" {
return pass(ev)
}
return fail(ev)
}
if status.Err != nil {
ev["error"] = status.Err.Error()
}
}
ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword")
ev := map[string]any{}
if askSource != "" {
ev["source"] = askSource
}
if ask.Err != nil {
if darwinDefaultsMissing(ask) {
ev["ask_for_password"] = "0"
ev["note"] = "askForPassword is unset or unavailable"
return fail(ev)
}
ev["error"] = ask.Err.Error()
ev["stderr"] = ask.Stderr
return unknown(ev)
}
enabled := strings.TrimSpace(ask.Stdout) == "1"
delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay")
ev["ask_for_password"] = ask.Stdout
if delayCmd.Err == nil {
ev["ask_for_password_delay"] = delayCmd.Stdout
if delaySource != "" && delaySource != askSource {
ev["delay_source"] = delaySource
}
}
if enabled {
return pass(ev)
}
return fail(ev)
}
func darwinScreenLockMode(raw string) (string, int, bool) {
lower := strings.ToLower(raw)
if strings.Contains(lower, "immediate") {
return "immediate", 0, true
}
if strings.Contains(lower, "off") {
return "off", -1, true
}
if before, _, ok := strings.Cut(lower, "seconds"); ok {
prefix := strings.Fields(before)
if len(prefix) == 0 {
return "seconds", -1, true
}
n, err := strconv.Atoi(prefix[len(prefix)-1])
if err != nil {
return "seconds", -1, true
}
return "seconds", n, true
}
return "", 0, false
}
func darwinFirewall(ctx context.Context) Result {
out := RunCommand(
ctx,
"defaults",
"read",
"/Library/Preferences/com.apple.alf",
"globalstate",
)
if out.Err == nil {
state := strings.TrimSpace(out.Stdout)
ev := map[string]any{"backend": "defaults", "global_state": state}
if state == "1" || state == "2" {
return pass(ev)
}
return fail(ev)
}
fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate")
ev := map[string]any{
"backend": "socketfilterfw",
"raw": fallback.Stdout,
"defaults_error": errString(out.Err),
"defaults_stderr": out.Stderr,
}
if fallback.Err != nil {
ev["error"] = fallback.Err.Error()
ev["stderr"] = fallback.Stderr
return unknown(ev)
}
if darwinStateIndicatesEnabled(fallback.Stdout) {
return pass(ev)
}
if darwinStateIndicatesDisabled(fallback.Stdout) {
return fail(ev)
}
return unknown(ev)
}
// darwinReadScreenSaverDefault prefers console-user settings when running as root.
func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, string) {
consoleUser := darwinConsoleUser(ctx)
if os.Geteuid() == 0 && consoleUser != "" {
var consoleMissing CmdResult
consoleMissingSource := ""
if CommandExists("sudo") {
consoleUserCurrentHost := RunCommand(
ctx,
"sudo",
"-u",
consoleUser,
"defaults",
"-currentHost",
"read",
"com.apple.screensaver",
key,
)
if consoleUserCurrentHost.Err == nil {
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
}
if !darwinDefaultsMissing(consoleUserCurrentHost) {
return consoleUserCurrentHost, "console_user_current_host:" + consoleUser
}
if consoleMissingSource == "" {
consoleMissing = consoleUserCurrentHost
consoleMissingSource = "console_user_current_host:" + consoleUser
}
consoleUserDomain := RunCommand(
ctx,
"sudo",
"-u",
consoleUser,
"defaults",
"read",
"com.apple.screensaver",
key,
)
if consoleUserDomain.Err == nil {
return consoleUserDomain, "console_user:" + consoleUser
}
if !darwinDefaultsMissing(consoleUserDomain) {
return consoleUserDomain, "console_user:" + consoleUser
}
if consoleMissingSource == "" {
consoleMissing = consoleUserDomain
consoleMissingSource = "console_user:" + consoleUser
}
}
plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist"
consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key)
if consoleUserOut.Err == nil {
return consoleUserOut, "console_user_plist:" + consoleUser
}
if !darwinDefaultsMissing(consoleUserOut) {
return consoleUserOut, "console_user_plist:" + consoleUser
}
if consoleMissingSource == "" {
consoleMissing = consoleUserOut
consoleMissingSource = "console_user_plist:" + consoleUser
}
if consoleMissingSource != "" {
return consoleMissing, consoleMissingSource
}
}
currentHost := RunCommand(ctx, "defaults", "-currentHost", "read", "com.apple.screensaver", key)
if currentHost.Err == nil {
return currentHost, "current_user_current_host"
}
currentUser := RunCommand(ctx, "defaults", "read", "com.apple.screensaver", key)
if currentUser.Err == nil {
return currentUser, "current_user"
}
if !darwinDefaultsMissing(currentUser) {
return currentUser, "current_user"
}
if !darwinDefaultsMissing(currentHost) {
return currentHost, "current_user_current_host"
}
return currentUser, "current_user"
}
func darwinDefaultsMissing(out CmdResult) bool {
lower := strings.ToLower(out.Stderr + "\n" + out.Stdout)
return strings.Contains(lower, "does not exist") ||
strings.Contains(lower, "could not find") ||
strings.Contains(lower, "does not exist in domain")
}
func darwinConsoleUser(ctx context.Context) string {
if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" {
return sudoUser
}
out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console")
if out.Err != nil {
return ""
}
user := strings.TrimSpace(out.Stdout)
if user == "" || user == "root" || user == "loginwindow" {
return ""
}
return user
}
func darwinStateIndicatesEnabled(raw string) bool {
lower := strings.ToLower(raw)
return strings.Contains(lower, "enabled") ||
strings.Contains(lower, "state = 1") ||
strings.Contains(lower, "state = 2")
}
func darwinStateIndicatesDisabled(raw string) bool {
lower := strings.ToLower(raw)
return strings.Contains(lower, "disabled") || strings.Contains(lower, "state = 0")
}
func darwinTimeSync(ctx context.Context) Result {
out := RunCommand(ctx, "systemsetup", "-getusingnetworktime")
if out.Err != nil || needsAdmin(out.Stdout) {
return unknown(
map[string]any{
"raw": out.Stdout,
"error": errString(out.Err),
},
)
}
on := strings.Contains(strings.ToLower(out.Stdout), "on")
ev := map[string]any{"raw": out.Stdout}
if on {
return pass(ev)
}
return fail(ev)
}
func darwinOSVersion(ctx context.Context) Result {
out := RunCommand(ctx, "sw_vers", "-productVersion")
if out.Err != nil || out.Stdout == "" {
return unknown(map[string]any{"error": "sw_vers failed"})
}
build := RunCommand(ctx, "sw_vers", "-buildVersion")
ev := map[string]any{
"product_version": out.Stdout,
"build_version": build.Stdout,
}
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 {
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",
darwinSoftwareUpdateManagedDomain,
key,
)
if managed.Err == nil {
return darwinSoftwareUpdatePref{
value: strings.TrimSpace(managed.Stdout),
source: darwinPrefSourceManaged,
}
}
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)
}
if len(indeterminate) > 0 {
ev["indeterminate_keys"] = indeterminate
return unknown(ev)
}
return pass(ev)
}
func darwinPrefIndicatesEnabled(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes":
return true
}
return false
}
func darwinPasswordPolicy(ctx context.Context) Result {
out := RunCommand(ctx, "pwpolicy", "-getaccountpolicies")
if out.Err != nil {
return unknown(
map[string]any{
"error": out.Err.Error(),
"stderr": out.Stderr,
},
)
}
lower := strings.ToLower(out.Stdout)
ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)}
if strings.Contains(lower, "no account policies") || lower == "" {
return fail(ev)
}
return pass(ev)
}
func darwinRemoteLogin(ctx context.Context) Result {
out := RunCommand(ctx, "systemsetup", "-getremotelogin")
if out.Err != nil || needsAdmin(out.Stdout) {
return unknown(
map[string]any{
"raw": out.Stdout,
"error": errString(out.Err),
},
)
}
off := strings.Contains(strings.ToLower(out.Stdout), "off")
ev := map[string]any{"raw": out.Stdout}
if off {
return pass(ev)
}
return fail(ev)
}
func darwinMalwareProtection(ctx context.Context) Result {
candidates := []string{
"/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
"/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist",
}
for _, path := range candidates {
if _, err := os.Stat(path); err != nil {
continue
}
ev := map[string]any{"engine": "XProtect", "plist": path}
version := RunCommand(
ctx,
"defaults",
"read",
strings.TrimSuffix(path, ".plist"),
"Version",
)
if version.Err == nil {
ev["version"] = version.Stdout
}
return pass(ev)
}
return fail(
map[string]any{
"engine": "XProtect",
"note": "XProtect.meta.plist not found in expected locations",
},
)
}
// needsAdmin checks systemsetup's stdout for privilege errors.
func needsAdmin(stdout string) bool {
return strings.Contains(strings.ToLower(stdout), "administrator access")
}