Software Updates, Bonus Round - SWU Bugged Fact

Here's a device fact/extension attribute you can use to see if the `softwareupdate` binary can't see the most recent macOS updates.

Software Updates, Bonus Round - SWU Bugged Fact

A bonus follow-up post regarding software update tracking. At the recommendation of @Chris in the MacAdmins Slack, and with assistance from @Pico, I've put together a fact that aggregates many of the individual checks from my previous posts into a more monolithic fact/extension attribute. Here's the logic flow:

  1. Get the currently-installed version of macOS
  2. Determine the most recently published update of all major macOS versions (this is for updates to the current OS, not upgrades to the latest major release)
  3. Determine what updates the Mac thinks are available
  4. Throw an error flag if the Mac thinks there are no updates available, but isn't running the most recently published update

The primary difference between this and my previous iterations of similar facts comes in the use of this webpage to update the definitions for the newest available macOS patches. By doing a curl of this page and then some cleanup of the output, you can get retrieve a list of the newest updates without having to manually update the script each month. A quick disclaimer, I did all my parsing of the output of that page with some pretty ugly sed and awk commands. In your environment, you can definitely clean up this process by replacing the string manipulation with JSON parsing.

The biggest benefit of using an external data source that Apple updates (so we don't have to) is that this simple curl is much faster and lighter weight than any query involving softwareupdate. Every MDM platform will have a different cadence for when scripts like these will be run, so our goal is to put as little pressure on that fragile little binary as possible.

Before we break it down, here's the whole script:

#!/bin/bash
# Ross Matsuda | Sudoade 2023
# Running latest update available update for current supported OS

# Output of true indicates softwareupdate is bugged
# Output of false indicates softwareupdate is fine

# Get current OS on device
currentOS=$(sw_vers -productVersion)
currentOSMajor=$(sw_vers -productVersion | awk -F. '{print $1}')
upToDate="false"
noUpdatesVisible="false"

# Bailout if Mac on 10.15 or earlier
if [ "$currentOSMajor" = 10 ]; then
  # echo "10.15 - Catalina or earlier, no action required"
  echo "false" # Not bugged
  exit 0
fi

### Define latest macOS versions
# Get list of OS versions
softwareupdatelist="$(curl -m 5 -sfL 'https://gdmf.apple.com/v2/pmv')"
# echo "$softwareupdatelist"

### Truncate list - leaving comments to explain function of each section
macOSonly=${softwareupdatelist#*macOS} #Strip out all content before first mention of "macOS"
  # macOStruncated="$(echo "$macOSonly" | tr , '\n' | grep "ProductVersion")" # Use commas for line breaks, remove all lines other than ProductVersion entries
  # macOStruncated1="$(echo "$macOStruncated" | sed '/iOS/q' | sed '$d')" #Remove all content after macOS versions listed
  # macOStruncated2="$(echo "$macOStruncated1" | awk -FProductVersion '{print $2}')" #Remove extraneous text
  # macOStruncated3="$(echo "$macOStruncated2" | sed 's/"//g' | sed 's/://g')" #Remove extraneous punctuation
speedTruncation="$(echo "$macOSonly" | tr , '\n' | grep "ProductVersion" | sed '/iOS/q' | sed '$d' | awk -FProductVersion '{print $2}' | sed 's/"//g' | sed 's/://g')"
 #echo " speed truncation output: $speedTruncation"

### Version Extractions
latest11="$(echo $speedTruncation | tr ' ' '\n' | grep 11)"
#echo "Big Sur: $latest11"
latest12="$(echo $speedTruncation | tr ' ' '\n' | grep 12)"
#echo "Monterey: $latest12"
latest13="$(echo $speedTruncation | tr ' ' '\n' | grep 13)"
#echo "Ventura: $latest13"

# Set Upgrade names to skip - delta upgrades started in macOS 12, Monterey
# Add macOS 14 to the skipStrings for 12 & 13 upon release
if [ "$currentOSMajor" = 13 ]; then
  #echo "13 - Ventura"
  # No skip strings yet, macOS 14 not named
  skipString=("null")
elif [ "$currentOSMajor" = 12 ]; then
  #echo "12 - Monterey"
  skipString=("Ventura")

# The below OS versions shouldn't need skipstrings
elif [ "$currentOSMajor" = 11 ]; then
  #echo "11 - Big Sur"
  # No skip strings, deltas not supported
  skipString=("null")
elif [ "$currentOSMajor" = 10 ]; then
  #echo "10.15 - Catalina or earlier"
  # No skip strings, deltas not supported
  skipString=("null")
fi
############ /VARIABLES ###########
###################################

# Usage: Is $1 at least $2
is_at_least () {
	if [[ $1 == $2 ]]; then
		return 0
	fi
	local IFS=.
	local i ver1=($1) ver2=($2)
	for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)); do
		ver1[i]=0
	done
	for ((i=0; i<${#ver1[@]}; i++)); do
		if [[ -z ${ver2[i]} ]]; then
			ver2[i]=0
		fi
		if ((10#${ver1[i]} > 10#${ver2[i]})); then
			return 0
		fi
		if ((10#${ver1[i]} < 10#${ver2[i]})); then
			return 1
		fi
	done
	return 0
}

# Per-OS checks
if [[ $currentOSMajor = 13 ]]; then
  # macOS Ventura (13) detected
  if is_at_least "$currentOS" "$latest13"; then
    upToDate="true"
  fi
elif [[ $currentOSMajor = 12 ]]; then
  # macOS Monterey (12) detected
  if is_at_least "$currentOS" "$latest12"; then
    upToDate="true"
  fi
elif [[ $currentOSMajor = 11 ]]; then
  # macOS Monterey (11) detected
  if is_at_least "$currentOS" "$latest11"; then
    upToDate="true"
  fi
else
    # If Major version is 10.x, bailout, do not flag for remediation
  echo "false"
  exit 0
fi

############# ALL UPDATES APPLIED LOGIC HERE ##############

namesOfUpdates="$(/usr/bin/defaults read /Library/Preferences/com.apple.SoftwareUpdate.plist RecommendedUpdates 2>/dev/null| grep "Display Name" | awk -F= '{print $2}' | sed 's/"//g' | sed 's/;//g')"

# Sample output:
# Safari
# macOS Ventura 13.1

# Now we're going to change the number of updates based on the presence of the skipString
finalUpdateList="$namesOfUpdates"
for i in "${skipString[@]}"; do
  finalUpdateList="$(echo "$finalUpdateList" | grep -v "$i")"
done

# Set variable - no updates detected after removing skipstring?
if [ "$finalUpdateList" = "" ]; then
  # echo "No updates detected"
  noUpdatesVisible="true"
fi

# Assess states
if [ "$noUpdatesVisible" = "true" ] && [ "$upToDate" = "true" ]; then
    # No updates detected, Mac on correct OS, device not bugged
    #echo "[TEST] No updates detected, Mac on latest OS"
    echo "false"
    exit 0
fi

if [ "$noUpdatesVisible" = "true" ] && [ "$upToDate" = "false" ]; then
    # No updates detected, Mac on inccorrect OS, device bugged
    #echo "[TEST] No updates detected, Mac not on latest OS"
    #echo "[RESULT] Bugged"
    echo "true"
    exit 0
fi

if [ "$noUpdatesVisible" = "false" ] && [ "$upToDate" = "true" ]; then
    # Updates detected, Mac on correct OS, device not bugged
    # Probably a Safari update or something visible
    #echo "[TEST] Updates detected, Mac on latest OS - Safari updates available"
    echo "false"
    exit 0
fi

if [ "$noUpdatesVisible" = "false" ] && [ "$upToDate" = "false" ]; then
    # Updates detected, Mac on incorrect OS, device not bugged
    #echo "[TEST] Updates detected, Mac not on latest OS - healthy"
    echo "false"
    exit 0
fi

Software Update Bugged fact

Let's take this apart one block at a time.

Lines 8-19: Current OS

# Get current OS on device
currentOS=$(sw_vers -productVersion)
currentOSMajor=$(sw_vers -productVersion | awk -F. '{print $1}')
upToDate="false"
noUpdatesVisible="false"

# Bailout if Mac on 10.15 or earlier
if [ "$currentOSMajor" = 10 ]; then
  # echo "10.15 - Catalina or earlier, no action required"
  echo "false" # Not bugged
  exit 0
fi

Lines 8-19: current macOS check, placeholder variable definitions, Catalina bailout

This is all very straightforward - we snag the current version of macOS on the device (the full version number and the major version number), give our most important device state variables placeholder values, and bail out if the fact is running on macOS 10.15 or earlier. In order to not act on older, incompatible operating systems later, we just exit with false and call it a day.

Lines 21-41: Most recent macOS releases

### Define latest macOS versions
# Get list of OS versions
softwareupdatelist="$(curl -m 5 -sfL 'https://gdmf.apple.com/v2/pmv')"
# echo "$softwareupdatelist"

### Truncate list - leaving comments to explain function of each section
macOSonly=${softwareupdatelist#*macOS} #Strip out all content before first mention of "macOS"
  # macOStruncated="$(echo "$macOSonly" | tr , '\n' | grep "ProductVersion")" # Use commas for line breaks, remove all lines other than ProductVersion entries
  # macOStruncated1="$(echo "$macOStruncated" | sed '/iOS/q' | sed '$d')" #Remove all content after macOS versions listed
  # macOStruncated2="$(echo "$macOStruncated1" | awk -FProductVersion '{print $2}')" #Remove extraneous text
  # macOStruncated3="$(echo "$macOStruncated2" | sed 's/"//g' | sed 's/://g')" #Remove extraneous punctuation
speedTruncation="$(echo "$macOSonly" | tr , '\n' | grep "ProductVersion" | sed '/iOS/q' | sed '$d' | awk -FProductVersion '{print $2}' | sed 's/"//g' | sed 's/://g')"
 #echo " speed truncation output: $speedTruncation"

### Version Extractions
latest11="$(echo $speedTruncation | tr ' ' '\n' | grep 11)"
#echo "Big Sur: $latest11"
latest12="$(echo $speedTruncation | tr ' ' '\n' | grep 12)"
#echo "Monterey: $latest12"
latest13="$(echo $speedTruncation | tr ' ' '\n' | grep 13)"
#echo "Ventura: $latest13"

Lines 21-41: curl Apple's list of available software updates and extract the latest version of each major version of macOS still in development (n-2)

For your own understanding, if you want a less horrific view of the gdmf webpage, run it through a JSON cleaner. The contents of the page are a few dictionaries that include the version numbers, release date, expiration date, and supported devices for macOS, iOS, iPadOS, and watchOS releases. Eagle-eyed admins will even catch the recently released Rapid Security Response (RSR) updates posted on May 1st, 2023, down at the bottom.

What we really care about though, are the first few levels of the page, PublicAssetSets/macOS, which contains the newest versions of Big Sur, Monterey, and Ventura, all the versions of macOS that are currently receiving updates/patches:

Sample of useful version information from https://gdmf.apple.com/v2/pmv

Since the document is formatted with the newest builds up at the top, it allows us to easily scrape the first version string we find for each major OS, clean it up, and set our variables. I left the logic behind the truncation necessary to clean up the curl for you to peruse, if helpful. In summary, it's ugly but it works. It'd be much more productive to manipulate the JSON itself to pull the ProductVersion values we want, but this gets the job done for now.

Lines 43-62: skipStrings

# Set Upgrade names to skip - delta upgrades started in macOS 12, Monterey
# Add macOS 14 to the skipStrings for 12 & 13 upon release
if [ "$currentOSMajor" = 13 ]; then
  #echo "13 - Ventura"
  # No skip strings yet, macOS 14 not named
  skipString=("null")
elif [ "$currentOSMajor" = 12 ]; then
  #echo "12 - Monterey"
  skipString=("Ventura")

# The below OS versions shouldn't need skipstrings
elif [ "$currentOSMajor" = 11 ]; then
  #echo "11 - Big Sur"
  # No skip strings, deltas not supported
  skipString=("null")
elif [ "$currentOSMajor" = 10 ]; then
  #echo "10.15 - Catalina or earlier"
  # No skip strings, deltas not supported
  skipString=("null")
fi

I went through this logic in one of my earlier posts - this is one of the only things you'll need to update semi-regularly about this script (assuming Apple doesn't perform any significant refactors of the site we took our info from). Now that macOS upgrades can be served as delta updates, they can appear in the softwareupdate binary's checks for available updates. We use the skipString arrays to detail the names of major operating system versions that may appear in the list of available updates so those lines can be ignored. Since the delta upgrade feature was introduced in macOS Monterey, we don't need skipString values for Big Sur or earlier (I left macOS 11 and 10.x in there as reminders, feel free to delete those lines). When macOS 14 is launched, its full name will need to be added to the Monterey and Ventura skipString arrays.

Lines 66-110: Up-to-date check

# Usage: Is $1 at least $2
is_at_least () {
	if [[ $1 == $2 ]]; then
		return 0
	fi
	local IFS=.
	local i ver1=($1) ver2=($2)
	for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)); do
		ver1[i]=0
	done
	for ((i=0; i<${#ver1[@]}; i++)); do
		if [[ -z ${ver2[i]} ]]; then
			ver2[i]=0
		fi
		if ((10#${ver1[i]} > 10#${ver2[i]})); then
			return 0
		fi
		if ((10#${ver1[i]} < 10#${ver2[i]})); then
			return 1
		fi
	done
	return 0
}

# Per-OS checks
if [[ $currentOSMajor = 13 ]]; then
  # macOS Ventura (13) detected
  if is_at_least "$currentOS" "$latest13"; then
    upToDate="true"
  fi
elif [[ $currentOSMajor = 12 ]]; then
  # macOS Monterey (12) detected
  if is_at_least "$currentOS" "$latest12"; then
    upToDate="true"
  fi
elif [[ $currentOSMajor = 11 ]]; then
  # macOS Monterey (11) detected
  if is_at_least "$currentOS" "$latest11"; then
    upToDate="true"
  fi
else
    # If Major version is 10.x, bailout, do not flag for remediation
  echo "false"
  exit 0
fi

Lines 66-110: Does the installed version of macOS match the most recent update published by Apple?

Now that we have the version numbers for the most recent builds, we can compare those to the currently-installed version, and determine if the current Mac is up-to-date. Remember, the logic of this script is all about targeting the latest version of the currently-installed OS, not upgrading to the latest major OS version. On the off-chance a Catalina-or-earlier Mac made it this far, we have another safety bailout. We'll store our results in a boolean to be called later.

Lines 112-130: How many updates are available?

############# ALL UPDATES APPLIED LOGIC HERE ##############

namesOfUpdates="$(/usr/bin/defaults read /Library/Preferences/com.apple.SoftwareUpdate.plist RecommendedUpdates 2>/dev/null| grep "Display Name" | awk -F= '{print $2}' | sed 's/"//g' | sed 's/;//g')"

# Sample output:
# Safari
# macOS Ventura 13.1

# Now we're going to change the number of updates based on the presence of the skipString
finalUpdateList="$namesOfUpdates"
for i in "${skipString[@]}"; do
  finalUpdateList="$(echo "$finalUpdateList" | grep -v "$i")"
done

# Set variable - no updates detected after removing skipstring?
if [ "$finalUpdateList" = "" ]; then
  # echo "No updates detected"
  noUpdatesVisible="true"
fi

Lines 112-130: Querying a file to see how many updates, skipping delta upgrades, the Mac has available

There's a .plist file that stores useful information from your last softwareupdate check - we'll query that file and get a list of the names of the available software updates. With those items listed, we then remove any updates that have the skipString value for newer major OS versions in them. If the list ends up empty, meaning either there are no updates visible or only delta upgrades visible, we set the noUpdatesVisible variable to true. This means the Mac believes it is fully updated.

Lines 132-161: Assess state

# Assess states
if [ "$noUpdatesVisible" = "true" ] && [ "$upToDate" = "true" ]; then
    # No updates detected, Mac on correct OS, device not bugged
    #echo "[TEST] No updates detected, Mac on latest OS"
    echo "false"
    exit 0
fi

if [ "$noUpdatesVisible" = "true" ] && [ "$upToDate" = "false" ]; then
    # No updates detected, Mac on inccorrect OS, device bugged
    #echo "[TEST] No updates detected, Mac not on latest OS"
    #echo "[RESULT] Bugged"
    echo "true"
    exit 0
fi

if [ "$noUpdatesVisible" = "false" ] && [ "$upToDate" = "true" ]; then
    # Updates detected, Mac on correct OS, device not bugged
    # Probably a Safari update or something visible
    #echo "[TEST] Updates detected, Mac on latest OS - Safari updates available"
    echo "false"
    exit 0
fi

if [ "$noUpdatesVisible" = "false" ] && [ "$upToDate" = "false" ]; then
    # Updates detected, Mac on incorrect OS, device not bugged
    #echo "[TEST] Updates detected, Mac not on latest OS - healthy"
    echo "false"
    exit 0
fi

Lines 132-161: Compare $noUpdatesVisible and $upToDate to determine if the Mac is not seeing software updates that it should be seeing

Using all of the information we've gleaned thus far, a final assessment of the device state is made. We're looking at all of the possible intersections of two variables, noUpdatesVisible and upToDate. In the Punnett square of results, only one quadrant results in a device state we'd consider "bugged" - if no updates are visible to the Mac, but the Mac is verified not to be running the latest patch.

With this information in hand, it's up to you to decide how you want to remediate. I've got this rigged up so any device that's in the "bugged" state (returns true in the above script) will have the softwareupdate binary kickstarted. Remember, Apple actively recommends against routinely kickstarting this binary as a proactive remediation measure. Scripts like this allow us to target devices with more specificity, only interacting with those that really need the extra push.