PSA: It’s Jamf Inventory Time, Do You Know Where Your Macs Are?

Or rather should I say, “Do you know when they are”? Tell me, Mac Admins, has this ever happened to you: You need to figure out when a computer last submitted inventory to Jamf. Seems easy, right? Just use the built-in Last Inventory criteria, right? Well, if you use the API to update computer records then the moment you do, that becomes the new Last Inventory date. The actual time when a Mac last sent it’s inventory is lost and can now only be loosely correlated with Last Check-In. Not ideal™

Does anyone really know what time it is?

Adding zLast Inventory (Actual) creates a safe harbor for the actual date of an inventory submission. It won’t change no matter how many API writes you make to the computer record. Just create a new extension attribute in your Jamf with an input type of Script and a data type of Date. Leave it in the General category, so it’s nearer to the built-in Last Inventory field in the UI. You might also want to add it as a default column in your Computer Inventory Display preferences and/or use it in Advanced Searches and reports where more reality is desired.

Additional Uses for finding victim’s of “Unknown Error”

With zLast Inventory (Actual) you may also be able to find Macs that are quietly failing to submit inventory. I came across a few Macs with a cryptic "Unknown Error" message a few months ago. When jamf recon runs it fails to upload the inventory which is bad enough but more insidiously the Last Inventory date in Jamf is updated anyway! The fix seemed to require removing both the Jamf framework on the endpoint and deleting the computer record in Jamf before re-enrolling. If you know what this is let me know! Anyway here it is, all two lines all gussied up with lots of comments.

The extension attribute

#!/bin/bash
# zLast Inventory (Actual) -  Copyright (c) 2025 Joel Bruner (https://github.com/brunerd/macAdminTools/tree/main/Jamf/EAs) Licensed under the MIT License
# A Jamf Pro Extension Attribute to report the _actual time_ of a successful inventory submission
# This is to address two issues with the built-in Last Inventory:
#  1) This date stamp is not affected by API writes to the computer record
#  2) Detect "Unknown Error" inventory submission failures if date does not match built-in Last Inventory date (excluding API writes in History)

# Notes:
# The "z" at the beginning of this EA's name is to ensure it is runs last and is closest to built-in Last Inventory datestamp
# The Jamf EA date type does not consider time zone offsets so we must normalize the time to UTC (-u) for the most consistent behavior
# When viewing an EA date in a Report, Inventory Display, or Computer Record it will not be localized according to Preferences as built-in dates are

#MySQL DATETIME format with time normalized to UTC (YYYY-MM-DD HH:MM:SS)
DATE_NORMALIZED=$(date -u +"%F %T")

echo "<result>${DATE_NORMALIZED}</result>"
It looks only slightly better in the UI…
Note: EA dates are not localized according to your UI prefs, unlike like built-in dates.

Ideas in Waiting

Apparently, my “maximizer” self can’t just write a dang two-line script without also thinking of all the “Jamf Ideas” that either need to be created and or upvoted in relation this. I encourage you to take a look at these and vote them up too.

  1. Inventory Time becomes incorrect with API (JN-I-16064)” – The “raison d’être” of this blog post, it shouldn’t be called Last Inventory if API writes are going to affect it also. This feature request dates back to May 22, 2014 (and I’m sure there are FRs that pre-date that!) It’s status is “Future Consideration”: At almost 11 years later, the future is here now Jamf!
  2. Localize Extension Attribute dates in the UI according to Account Preferences (JPRO-I-1068)” – As you can see above, the dates from Extension Attributes are not localized like the “built-in” dates of Last Update and Last Inventory. Why not?
  3. Expanded parsing of date and time formats in extension attribute(JPRO-I-1078)” – Jamf hitched it’s wagon to MySQL’s DATETIME format YYYY-MM-DD HH:MM:SS which was introduced in 1999 and has no provisions for time zone. In 2025 this seems somewhat rudimentary. Jamf could expand what’s accepted for date types to accommodate more date time representations or at least in the short term document the fact you need normalize all times to UTC before sending.
  4. Ability to customize order of display fields in Reports (JN-I-22451)” – Another “oldie but goody” from Jan 22, 2014 (Happy belated 11th Birthday!) it has 575 votes and has to be one of the most requested features that’s still under “Future Consideration” (again, the future is now Jamf). If we have to make an extension attribute to get some truth for Last Inventory can we at least have it show up somewhere we’d like? This request has many siblings, some that should be merged here, here, here, here and ones that were merged here and here.
  5. Oh yeah, almost forgot, you cannot “Retain Display items when Cloning Saved Advanced Search (JN-I-22827) I thought I was going crazy after I’d made a series of reports by cloning, only to find the “clones” had only copied the search criteria but not the all those Display items I’d spent time checking off – that I had to do again and again. [Edit: I might have misremembered and you have to uncheck the default Display items you don’t want re-enabled in your “clone” so this still holds true] Aye, what kinda “clone” is that?! It’s not! This “idea” is labeled Not likely to implement but hey, you can still leave your angsty comments in memoriam like me, vote it up, and pour outta 40. 🪦🍻 [ Edit: Yes, these are “first world” nerd problems, I know. Blame it on the bad early 2025 vibes.]

Where was I? Oh right, the thing…

In closing, add zLast Inventory (Actual) as an extension attribute to your Jamf. You never know when you might need a bit of reality for Last Inventory in your Jamf reports and searches.

Detecting Apple Intelligence and ChatGPT Integration Status

UPDATE: Heads up, either by shear coincidence or my salty appeal to not play hide and seek, the ability to detect Apple Intelligence will no longer be found in the binary plist data of com.apple.gms.availability.key in the user’s .GlobalPreferences domain on macOS 15.4 and up. I’ve updated my GitHub script OS-Apple Intelligence Availability.sh and I’ve also added a simple POC one-liner below.

Run a Jamf shop? Would you like to collect stats on who’s turned on Apple Intelligence so far? That is, if your company hasn’t already had you put the kibosh on it! Personally I think Apple Intelligence and it’s Private Cloud Compute are a secure and private way to use things like the Writing Tools to proofread, make list or tables, etc. without wondering if your corporate data is being used in further LLM training. If your company is so enlightened and you’d like to see which Macs have Apple Intelligence enabled, read on! Or perhaps, you’d just like to see how it’s done. It’s not as straightforward as you might think, Apple engineers are really into obfuscation these days!

Finding the Suspects

The first part of the fun is hunting down which file might indicate the status of Apple Intelligence. I closed all other apps except System Settings, toggled Apple Intelligence on and off and tried a few things: Looking at /Library/Preferences in Finder sorted by date (in this case make sure to show hidden files), running FSMonitor to see things visualized, running fs_usage -w -f filesys | grep plist while screen recording in Quicktime to see the exact moment I enabled/disable Apple Intelligence. After some toggling it is determined that ~/Library/Preferences/.GlobalPreferences.plist is our target. A few days later I re-discovered Bob Gendler’s excellent post about using the command log stream --debug --predicate 'process == "cfprefsd" && eventMessage CONTAINS "wrote the key"' to help narrow things down and this also confirmed this is the file (as well as many others) being written to when it’s enabled.

Obfuscation Investigations

Let’s look at ~/Library/Preferences/.GlobalPreferences.plist and see if we can find the exact key name. This file is a bit special in that you can do the same thing 5 6 different ways with defaults! Run one of these commands as the console user not root:

  • defaults read ~/Library/Preferences/.GlobalPreferences.plist
  • defaults read ~/Library/Preferences/.GlobalPreferences
  • defaults read NSGlobalDomain
  • defaults read -globalDomain
  • defaults read -g
  • defaults read "Apple Global Domain"
    • Bonus update: 👆Shows up in defaults read and nowhere else

Regardless of how we do it though, the output for the com.apple.gms.availability keys is abridged and useless to us in this form.

What’s going on? What is this halting hexadecimal hodgepodge? Let’s use defaults export -g - to print the contents to in XML1 format (BTW you won’t find export documented in the man page, after a decade it still only exists in defaults -h help).

Ah, OK, these keys are <data> types which contain base64 encoded strings. The contents are possibly binary data. We’ve already established that defaults is not going to be useful to us. In my previous post Respecting Focus and Meeting Status in Your Mac scripts (aka Don’t Be a Jerk) we are able to extract the data using plutil -extract however because these key names contains periods (aka “full stop” U+2E) this clashes with plutil’s shoddy “keypath” parsing, which uses periods to delimit the path but doesn’t respect escaping periods with a backslashes (which is not hard to do as my JSON tool ljt can handles this). So all we are left with is /usr/libexec/PlistBuddy let’s give that a try: /usr/libexec/PlistBuddy ~/Library/Preferences/.GlobalPreferences.plist -c "print :com.apple.gms.availability.key (Spoiler alert the key is: com.apple.gms.availability.key)

OK! There we go, it’s a binary plist inside the key of a plist! “Brilliant” Apple, really. 🙄 Alright then, let’s pipe this through plutil -convert xml1 - -o - and get some ASCII XML yeah?

Pardon moi, but what the shit is this!? 💩 file said it was an “Apple binary property list” – according to those first bytes. Let’s look at it run through xxd to see if we can find more clues.

Aha! There is an extraneous newline (0x0a) that PlistBuddy is outputting (annoying but at least consistent!). Why can’t that just be ignored? Because apparently the last byte of a binary plist is the offset table, mess that up and it all falls apart. So what we need to do is trim this dangling newline. For this I consulted ChatGPT and perl -pe 'chomp if eof' is the magic we need. BTW ChatGPT does well for me to ask very targeted and direct questions like this, I don’t ask it to write entire scripts but sometimes it will have insights into different methods I’d never considered.

OMG. SRSLY? Apple is writing data encoded binary plist data containing a single integer value in an array?! ATTN: Craig Federighi: Please tell your engineers to stop junking up an otherwise elegant system with these obfuscation games. Alright, let’s bring it home and get the value with this: /usr/libexec/PlistBuddy ~/Library/Preferences/.GlobalPreferences.plist -c "print :com.apple.gms.availability.key" | perl -pe 'chomp if eof' | plutil -convert xml1 - -o - | plutil -extract 0 raw - -o -

There we go: 0 (zero), which means Apple Intelligence is on. When it’s 2, it’s off. Yet another key like PrivateMACAddressModeSystemSetting which has the opposite meaning of what you might expect. BTW I’ve yet to see the value as 1, except for this June Tweet where someone writes a 1 to turn on Writing Tools on a beta. BTW this key only indicates the state, in my testing it does not affect the state, meaning if you change the value (and even do a killall cfprefsd) all it will do is blank out the Apple Intelligence toggle for a moment and then it’ll sort itself out and display the true state.

[UPDATE: Here’s a simple hacky POC method that works on all version of Sequoia: awk -F '= |;' '/=/{print $2}' <<< "$(defaults read ~/Library/Preferences/com.apple.CloudSubscriptionFeatures.optIn.plist)" My script at OS-Apple Intelligence Availability.sh does a more thorough job get the correct iCloud AccountDSID. ]

And yes there is another way to get the state of Apple Intelligence however it makes the assumption that you only have one account in MobileMeAccounts.plist to determine your AccountDSID and then query yet another file but if you have more than one Apple Account, you know like for all your purchases in: iTunes/Music/App Store there may be multiple accounts (** cough** merge accounts Apple! **cough**) so it may return an erroneous results if you have more than one account. [UPDATE: Actually the cause of more than one dictionary in the Accounts array in MobileMeAccounts.plist is from additional Mail accounts. My updated script has a quick and novel way to get the correct AccountDSID. Also, you can now Migrate purchases from one Apple Account to another Apple Account too! ❤️]

The ChatGPT integration is actually a cinch to determine. I used the same method above to find the plist and then, because the engineers on this didn’t play games with encoded binary plist data within a plist, all you need to do is simply ask: defaults read com.apple.siri.generativeassistantsettings isEnabled and amazingly 0 means off and 1 means on, imagine that!

Usable Extension Attributes

You’ve made it this far, here’s the goods: OS-Apple Intelligence Availability.sh [now updated for 15.4] and OS-Apple Intelligence ChatGPT Status.sh they are fully commented and when you run them here’s what they return. I’m trying out a new way to output both values and their interpretations, because let’s be honest, we are “reading tea leaves” here. 🍵 None of this is officially documented by Apple and always subject to change. If a new value pops up, it will still be reported in the output. You won’t have to scramble to fix the extension attribute right away, just adjust the criteria in your Smart Group to match the value in the parentheses if there’s a policy that depends on it.

Closing Thoughts

What an absolute pain it was to get a single numerical value from a single key! To that end after I had written these extension attributes I went to work over the Thanksgiving break and wrote a tool to make this effortless. I’ll be releasing this tool, which will work as both a standalone tool and a shell script function in the coming days!

Getting Ahead of Private Wi-Fi Address Changes in macOS Sequoia

It’s been a busy 2 weeks since Apple released macOS Sequoia! One of the new features is private Wi-Fi addresses aka “MAC address randomization”. While this all well-and-good and can “improve your privacy”, it has been causing headaches for folks in the environments where random MAC address are not a good thing.

Now, Apple did think about this somewhat as evidenced by: About private Wi-Fi addresses and enterprise networks and Wi-Fi MDM settings for Apple devices but they didn’t really do a complete “dress rehearsal” of what would happen: You can’t deploy an MDM WiFi profile with the DisableAssociationMACRandomization key until the Mac is already upgraded to macOS Sequoia and once upgraded to Sequoia the MAC randomizes and that could break your connectivity to get the new config profile! It’s a real “chicken and egg problem” 🐣 that is laid out in great detail here.

Seeing the angst created, the contortions required, and overall FML vibes this was causing I did some investigations to see what could be done and guess what? You can pre-populate the PrivateMACAddressModeUserSetting value to off in the wifi.network.ssid.<SSID> dictionary of /Library/Preferences/com.apple.wifi.known-networks.plist before a Mac upgrades to Sequoia! 🎉 Note: When you set this via script (versus GUI), the change does not take effect until reboot, the MAC stays randomized and the UI does not reflect this. This doesn’t matter if it’s for an upgrade to Sequoia but just letting you know if you try it on Macs already on Sequoia, it won’t take effect until a reboot. If there’s some clever kill -HUP that can be sent let me know. I tried killall cfprefsd but that wasn’t enough. Update: Thanks to boberito in the MacAdmins Slack, he figured out that killing cfprefsd and airportd would update the UI and power cycling the Wi-Fi would cause this change to take effect! I’ve added an option to the script to do this, set restartWiFi_HC="1" to restart Wi-Fi, just be aware you better make sure your Wi-Fi reconnects!

If that’s all you need to know, then god-speed and good luck to you! If you’d like a Jamf script and extension attribute read on…

You can use the script setPrivateMACAddressMode to set the mode of one or more SSIDs on a target Mac. As it is, the SSID must have been previously connected to so the wifi.network.ssid.<SSID> gets filled out. I tried writing the value to an un-populated dictionary and Wi-Fi just wigged out (like nothing in the SSID list kinda weirdness!). Keep that caveat in mind. If you can figure out a minimum viable set of keys that are needed then good for you but for this script, it’ll just skip the SSID if it’s not in alrready in com.apple.wifi.known-networks. This script can be used with Jamf and its policy script parameters or if you are on another MDM, just hard-code the SSIDs, I’ve made allowances for that.

Next, this is a Jamf extension attribute OS-Private MAC Address Mode it will report the mode and the SSID (example: off|My Cool Wifi). You can have it report all known networks or just hardcode the value for specific SSID(s). It can be useful when used in a Smart Group. You could deploy a Major Update deferral config profile to block Sequoia then make a Smart Group for exclusion using OS-Private MAC Address Modeis likeoff or ‘matches regex^off\|for more precise matching . When someone becomes a member the Smart Group it excludes them from the Major Update deferral config profile and can then upgrade to Sequoia.

Lastly, if you don’t have Jamf but want to see what the values are getPrivateMACAddressMode will spit out a a human readable list of SSIDs and modes (off, static, rotating, and NOT_SET)

I usually try to put more pictures and illustrations in my posts but every moment I spend not publishing this, the more weekends that are possibly ruined by thinking about the suck that is macOS Sequoia Private Wi-Fi addresses in managed environments. I think this pic will sum things up! 😄

UPDATES: A couple things to keep in mind: 1) If you deploy Config Profiles with Wi-Fi payloads, then it will blow away everything about the SSID com.apple.wifi.known-networks every time the config profile is redeployed! 2) That means if you do deploy a Wi-Fi config profile with the Sequoia-only DisableAssociationMACRandomization key it will blow away the manually set key PrivateMACAddressModeUserSetting in com.apple.wifi.known-networks. The EA OS-Private MAC Address Mode currently does not attempt to resolve if an SSID has a corresponding config profile profile with the DisableAssociationMACRandomization key set, so it will be blank. 3) As of 15.0.1 a user can still change the setting even when deployed via MDM, contrary to Apple’s stated behavior: “This value is only locked when MDM installs the profile. If the profile is manually installed, the system sets the value but the user can change it”. 3b) Actually I found that if you killall airportd before rebooting, it’ll lock the value – until reboot. 4) Apple also draws user attention to setting with a caution icon ⚠️ in the Wi-Fi dropdown rather than a “check” icon like they use for managed Login Items in System Settings (the whole thing is an inconsistent mess)

Update 2: Steven Xu over in the Jamf Nation forums found that setting the PrivateMACAddressModeSystemSetting key to 1 (integer, not boolean) within /Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist will disable Private MAC Addresses by default. Newly joined networks or networks where the PrivateMACAddressModeUserSetting key for an SSID is off or not already set to static or rotating will use their actual MAC address. I’ve added support for setting this key in setPrivateMACAddressMode and it too will not take effect in Sequoia+ until either a reboot or setting the variable to restartWiFi_HC="1". Just keep in mind, perhaps you want your users to have Private MAC addresses by default when joining new and unknown networks? Why should the free Wi-Fi at the food court or coffee shop get your real MAC address anyway!? The getPrivateMACAddressMode script and extensions attribute OS-Private MAC Address Mode have been updated to report on (disable)PrivateMACAddressModeSystemSetting also.

Code in this post:

Avoid Unicode Mangling in Jamf with shef

Computers are “the equivalent of a bicycle for our minds” as Steve Job once said. Sure enough, like a bicycle, they also require maintenance! Sometimes that requires soliciting the user to take action. Asking at the right time is very important (see my post Don’t Be a Jerk) and so is brevity. I’ve found a sprinkling of emoji and other symbols can help your message cut through the noise. A bright red stop sign 🛑 can convey its meaning with only a glance, especially for non-native speakers of your language. Your tooling however, may trip you up and mangle your text.

While AppleScript, Swift Dialog and JamfHelper can all handle Unicode, Jamf databases tables by default do not support 4-byte Unicode! I touched on this in my post: jpt 1.0 text encoding fun. While the database engine might support it, chances are your tables are Latin1 which top out at 3-byte UTF-8 encoded characters. 4-byte characters get mangled. Let’s take a look at this in action:

Looks good while editing…
Once saved, the mangling is clear

Only the gear makes it through because it’s actually two 3-byte characters (U+2699 ⚙ gear plus variation selector U+FE0F) but who has time to check which one is which? How about an encoding tool written in shell, that allows you to escape and format Unicode for shell scripts and/or Jamf script parameters that can make it through unscathed and un-mangled? Sound good? Great! I present shef, the Shell Encoder and Formatter!

Give it some text, specify an encoding scheme and/or quoting style and out comes your string ready for use! You can put the resulting string in your script as a variable or as a script parameter in a Jamf policy depending on quoting options. I’ve done the hard work of finding the various ways to escape special characters for shell and made this handy script for you!

shef Examples

First, let’s make a simple script for Jamf that processes the output of shef. I’ve chosen AppleScript so you can play along at home even if you don’t have Jamf installed, this can also be applied to “wrapper scripts” that leverage other tools like Swift Dialog or JamfHelper. At it’s heart is the simple technique of encoding the input with shef then decoding it in the script before presentation. If you use bash use echo -e if you use sh or zsh then just echo will work, it’s that simple. Our example script simpleAlert-AS.sh, keeps things small and as minimal as possible but keep in mind my fuller-featured tool shui for more robust scripts where you may need text or password entry, file picking, etc. without external dependencies but don’t want to bother learning AppleScript.

#!/bin/bash
#simpleAlert-AS - Copyright (c) 2023 Joel Bruner (https://github.com/brunerd)
#Licensed under the MIT License

#Simple Applescript alert dialog for Jamf - just a title, a message and an OK button
#Accepts hex (\xnn) and octal (\0nnn) escaped UTF-8 encoded characters (since the default Jamf db character set mangles 4 byte Unicode)
#Use shef to encode your strings/files for use in this script: https://github.com/brunerd/shef

#function to interpret the escapes and fixup characters that can screw up Applescript if unescaped \ and "
function interpretEscapesFixBackslashesAndQuotes()(echo -e "${@}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')

function jamflog(){
	local logFile="/var/log/jamf.log"
	#if we cannot write to the log or it does not exist, unset and tee simply echoes
	[ ! -w "${logFile}" ] && unset logFile
	#this will tee to jamf.log in the jamf log format: <Day> <Month> DD HH:MM:SS <Computer Name> ProcessName[PID]: <Message>
	echo "$(date +'%a %b %d %H:%M:%S') ${myComputerName:="$(scutil --get ComputerName)"} ${myName:="$(basename "${0}" | sed 's/\..*$//')"}[${myPID:=$$}]: ${1}" | tee -a "${logFile}" 2>/dev/null
}


#process our input then escape for AppleScript
message=$(interpretEscapesFixBackslashesAndQuotes "${4}")
title=$(interpretEscapesFixBackslashesAndQuotes "${5}")
#could be a path or a built-in icon (stop, caution, note)
icon="${6}"
#invoke the system open command with this argument (URL, preference pane, etc...)
open_item="${7}"

#these are the plain icons (Applescript otherwise badges them with the calling app)
case "${icon}" in
	"stop") icon="/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/AlertStopIcon.icns";;
	"caution") icon="/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/AlertCautionIcon.icns"
		#previous icon went away in later macOS RIP
		[ ! -f "${icon}" ] && icon="/System/Library/CoreServices/Problem Reporter.app/Contents/Resources/ProblemReporter.icns";;
	"note") icon="/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/AlertNoteIcon.icns";;
esac

#make string only if path is valid (otherwise dialog fails)
if [ -f "${icon}" ]; then
	withIcon_AS="with icon file (POSIX file \"${icon}\")"
fi

jamflog "Prompting user: $(stat -f %Su /dev/console)"

#prompt the user, giving up and moving on after 1 day (86400 seconds)
/usr/bin/osascript <<-EOF
activate
with timeout of 86400 seconds
	display dialog "${message}" with title "${title}" ${withIcon_AS} buttons {"OK"} default button "OK" giving up after "86400"
end timeout
EOF

if [ -n "${open_item}" ]; then
	jamflog "Opening: ${open_item}"
	open "${open_item}"
fi

exit 0

Upload the above script to your Jamf (if you have one), label parameters 4, 5, 6, and 7 as: Message, Title, Icon Path, an Open After OK, respectively. If running local keep this in mind and put 1, 2, 3 as place holder arguments for the first three parameters.

Jamf Script parameters names for

Let’s come up with an example message for our users. How about the common refrain coming from MacAdmins around the world:

🛑 Stop.
⚙️ Run your updates.
🙏 Thanks!

Now if I tried to pass this text to a script within a Jamf policy, all those emoji would get mangled by Jamf as we saw above. Let’s use shef to encode the string for Jamf:

% shef <<'EOF'
heredoc> 🛑 Stop.
heredoc> ⚙️ Run your updates.
heredoc> 🙏 Thanks!
heredoc> EOF
\xF0\x9F\x9B\x91 Stop.\n\xE2\x9A\x99\xEF\xB8\x8F Run your updates.\n\xF0\x9F\x99\x8F Thanks!

For the example above I am using a “here-doc”; in practice you can simply supply shef a file path. The output encodes all the newlines in the ANSI-C style of \n and the emoji have all been replaced by their UTF-8 encodings using the hexadecimal escaping of \x. We can take this output and use it in our Jamf policy. The message will get through both textually and symbolically. As an additional feature bonus I added a simple open action in the script. Supply the file path to the Software Update panel and after the user clicks OK, it will be opened. Scope a policy like this to anyone with pending updates with Daily frequency:

Escape the mangling in Jamf!

If you are just running the script locally and calling from the Terminal you’ll want to specify a quoting option. Exclamations are tricky and shells usually love to interpret trailing exclamations as a history expansion command, shef does its best to avoid this:

bash-3.2$ shef -Qd <<'EOF'
> 🛑 Stop.
> ⚙️ Run your updates.
> 🙏 Thanks!
> EOF
"\xF0\x9F\x9B\x91 Stop.\n\xE2\x9A\x99\xEF\xB8\x8F Run your updates.\n\xF0\x9F\x99\x8F Thanks"\!""

bash-3.2$ ./simpleAlert-AS.sh 1 2 3 "\xF0\x9F\x9B\x91 Stop.\n\xE2\x9A\x99\xEF\xB8\x8F Run your updates.\n\xF0\x9F\x99\x8F Thanks"\!"" "Software Updates Pending" stop "/System/Library/PreferencePanes/SoftwareUpdate.prefPane"
Our un-mangled output

Stop the Mangling Madness!

Wrapping things up: use shef to encode strings with Unicode so they survive storage in Jamf’s Latin1 encoded db tables. shui my fuller featured AppleScript dialog tool is ready to accept shef encoded strings. You can also use shef minify your text for your shell script. In fact, I used it to encode the help text file down to a single quoted line for use within itself. That’s 1 happy customer and counting, hope you find it useful too!

Determining Adobe Creative Cloud sign-in status on a Mac

Adobe Creative Cloud app can get in weird state that blocks users from seeing their apps.

The official solution from Adobe is to delete the file /Library/Application Support/Adobe/OOBE/Configs/ServiceConfig.xml and “restart” Adobe CC, however in the real world you need to sign out and sign back in for the fix to take effect. The folks in the Adobe forums also found that you can change the value of the AppsPanel key from false to true and that does the trick too. Either way it still requires the user to sign-out of Adobe CC and back in.

Now, if you are a MacAdmin with a script that either deletes or modifies ServiceConfig.xml, you will ideally tell the user to Sign Out of Creative Cloud for the fix to take effect. Wouldn’t it be nice to be able to check they’ve done so and prompt them appropriately (or not)? For sure, right!?

There’s a per-user file with ideal behavior, that gets created upon sign-in and removed upon sign out: ~/Library/Application Support/Adobe/Creative Cloud\ Libraries/LIBS/librarylookupfile While its presence shouldn’t be taken as absolute proof that the sign-In is valid, it can be helpful when you need to alert the user what their next steps are. Here’s a simple example that just echoes out the status:

#!/bin/bash
#consoleHasAdobeCCSignIn - Copyright (c) 2022 Joel Bruner - MIT License

function consoleHasAdobeCCSignIn()(
	consoleUser=$(stat -f %Su /dev/console)

	#if root grab the last console user
	if [ "${consoleUser}" = "root" ]; then
		consoleUser=$(/usr/bin/last -1 -t console | awk '{print $1}')
	fi
	
	sudo -u ${consoleUser} sh -c 'ls ~/Library/Application\ Support/Adobe/Creative\ Cloud\ Libraries/LIBS/librarylookupfile &>/dev/null'
	return $?
)


if consoleHasAdobeCCSignIn; then
	result="Signed In"
else
	result="Signed Out"
fi

echo "Adobe CC status ($(stat -f %Su /dev/console)): $result"
Example output

In a fully fleshed out script you could use my shell function shui to pop-up a sharp looking AppleScript alert to your user if they need to sign out or just sign in. You can even use the icon in your pop-up.

You could also use the consoleHasAdobeCCSignIn function in a Jamf Extension Attribute for tracking which Macs have actually signed into Adobe CC and possibly reclaim unused licenses. I’ll leave the uses and script cobbling to you the reader, as an exercise. Thanks for reading!

Determining iCloud Drive and Desktop and Documents Sync Status in macOS

I’m on a roll with iCloud stuff. In this post I’d like to show you how you can determine if either iCloud Drive is enabled along with the “Desktop and Documents Folders” sync feature. While you can use MDM to turn these off, perhaps you like to know who you’d affect first! Perhaps the folks in your Enterprise currently using these features are in the C-Suite? I’m sure they’d appreciate a heads up before all their iCloud docs get removed from their Macs (when MDM disallowance takes affect it is swift and unforgiving).

iCloud Drive Status

When it comes to using on-disk artifacts to figure out the state of macOS I like to use the analogy of reading tea leaves. Usually it’s pretty straightforward but every now and then there’s something inscrutable and you have to take your best guess. For example iCloud Drive status is stored in your home folder at ~/Library/Preferences/MobileMeAccounts.plist but yet the Accounts key is an array. The question is how and why you could even have more than one iCloud account signed-in?! Perhaps a reader will tell me when and how you would ever have more than one? For now it seems inexplicable.

Update: It seems quite obvious now but as many folks did point out, of course you can add another iCloud account to Internet Accounts and it can sync Mail, Contacts, Calendars, Reminders, and Notes, just not iCloud Drive. Only one iCloud Drive user per user on a Mac. While I do have another AppleID I only use it for Media and Purchases and none of the other iCloud services. The code below stays the same as it is looking at all array entries. Aside: Boy, do I wish I could merge or transfer my old iTunes purchasing/media Apple ID with my main iCloud Apple ID! <weakly shakes fist at faceless Apple bureaucracy that for some reason hasn't solved the problem of merging Apple IDs>

Regardless of that mystery jpt can use the JSONPath query language to get us an answer in iCloudDrive_func.sh and the minified iCloudDrive_func.min.sh. Below is an edited excerpt:

#!/bin/bash
#Joel Bruner - iCloudDrive.func.sh - gets the iCloud Drive status for a console user

#############
# FUNCTIONS #
#############

function iCloudDrive()(

	#for brevity pretend we've pasted in the minified jpt function:
	#https://github.com/brunerd/jpt/blob/main/sources/jpt.min
	#for the full function see https://github.com/brunerd/macAdminTools/tree/main/Scripts

	consoleUser=$(stat -f %Su /dev/console)

	#if root grab the last console user
	if [ "${consoleUser}" = "root" ]; then
		consoleUser=$(/usr/bin/last -1 -t console | awk '{print $1}')
	fi

	userPref_json=$(sudo -u $consoleUser defaults export MobileMeAccounts - | plutil -convert json - -o -)

	#pref domain not found an empty object is returned
	if [ "${userPref_json}" = "{}" ]; then
		return 1
	else
		#returns the number paths that match
		matchingPathCount=$(jpt -r '$.Accounts[*].Services[?(@.Name == "MOBILE_DOCUMENTS" && @.Enabled == true)]' <<< "${userPref_json}" 2>/dev/null | wc -l | tr -d "[[:space:]]")
	
		if [ ${matchingPathCount:=0} -eq 0 ]; then
			return 1
		else
			return 0
		fi
	fi
)
########
# MAIN #
########

#example function usage, if leverages the return values
if iCloudDrive; then
	echo "iCloud Drive is ON"
	exit 0
else
	echo "iCloud Drive is OFF"
	exit 1
fi

The magic happens once we’ve gotten the JSON version of MobileMeAccount.plist I use jpt to see if there are any objects within the Accounts array with Services that have both a Name that matches MOBILE_DOCUMENTS and have an Enabled key that is set to true, the -r option on jpt tells it to output the the JSON Pointer path(s) the query matches. I could have used the -j option to output JSONPath(s) but either way a line is a line and that’s all we need. Altogether it looks like this: jpt -r '$.Accounts[*].Services[?(@.Name == "MOBILE_DOCUMENTS" && @.Enabled == true)]

Again because Accounts is an Array we have it look at all of them with $.Accounts[*]and in the off chance we get more than one we simply say if the number of matches is greater than zero then it’s on. This works very well in practice. This function could best be used as a JAMF Extension Attribute. I’ll leave that as a copy/paste exercise for the reader. Add it to your Jamf Pro EAs, let sit for 24-48 hours and check for results! ⏲ And while some of you might balk at a 73k Extensions Attribute, the execution time is on average a speedy .15s!

#!/bin/bash
#a pretend iCloudDrive Jamf EA 
#pretend we've pasted in the function iCloudDrive() above

if iCloudDrive; then
	result="ON"
else
	result="OFF"
fi

echo "<result>${result}</result>"

iCloud Drive “Desktop and Documents” status

Thankfully, slightly easier to determine yet devilishly subtle to discover, is determining the status of the “Desktop and Documents” sync feature of iCloud Drive. After searching in vain for plist artifacts, I discovered the clue is in the extended attributes of your Desktop (and/or Documents) folder! You can find the scripts at my GitHub here iCloudDriveDesktopSync_func.sh and the minified version iCloudDriveDesktopSync_func min.sh

#!/bin/bash
#Joel Bruner - iCloudDriveDesktopSync - gets the iCloud Drive Desktop and Document Sync Status for the console user

#############
# FUNCTIONS #
#############

#must be run as root
function iCloudDriveDesktopSync()(
	consoleUser=$(stat -f %Su /dev/console)

	#if root (loginwindow) grab the last console user
	if [ "${consoleUser}" = "root" ]; then
		consoleUser=$(/usr/bin/last -1 -t console | awk '{print $1}')
	fi

	#if this xattr exists then sync is turned on
	xattr_desktop=$(sudo -u $consoleUser /bin/sh -c 'xattr -p com.apple.icloud.desktop ~/Desktop 2>/dev/null')

	if [ -z "${xattr_desktop}" ]; then
		return 1
	else
		return 0
	fi
)

#example function usage, if leverages the return values
if iCloudDriveDesktopSync; then
	echo "iCloud Drive Desktop and Documents Sync is ON"
	exit 0
else
	echo "iCloud Drive Desktop and Documents Sync is OFF"
	exit 1
fi

The operation is pretty simple, it finds a console user or last user, then runs the xattr -p command as that user (anticipating this being run as root by Jamf) to see if the com.apple.icloud.desktop extended attribute exists on their ~/Desktop. In testing you’ll find if you toggle the “Desktop and Documents” checkbox in the iCloud Drive options, it will apply this to both of those folders almost immediately without fail. The function can be used in a Jamf Extension Attribute in the same way the iCloudDrive was above. Some assembly required. 💪

So, there you go! Another couple functions to read the stateful tea leaves of iCloud Drive settings. Very useful if you are about to disallow iCloud Drive and/or Desktop and Document sync via MDM but need to know who you are going to affect and let them know beforehand. Because I still stand by this sage advice: Don’t Be A Jerk. Thanks for reading you can find these script and more at my GitHub repo. Thanks for reading!

Determining “iCloud Private Relay” and “Limit IP tracking” status in macOS

If you are a Mac admin you might have noticed some Apple plists are more complex than others these days. As evidenced in my post Don’t Be a Jerk, getting the status of Do Not Disturb in Big Sur was a multi-step exercise. Check out this modified code excerpt from doNotDisturb‘s Big Sur handling

#!/bin/sh

#Big Sur DnD status, returns "true" or [blank] (to be run as console user)
dndStatus="$(/usr/libexec/PlistBuddy -c "print :userPref:enabled" /dev/stdin 2>/dev/null <<< "$(plutil -extract dnd_prefs xml1 -o - /dev/stdin <<< "$(defaults export com.apple.ncprefs.plist -)" | xmllint --xpath "string(//data)" - | base64 --decode | plutil -convert xml1 - -o -)")"

#if we have ANYTHING it is ON (return 0) otherwise fail (return 1)
[ -n "${dndStatus}" ] && { echo ON; exit 0; } || { echo OFF; exit 1; }

Why is it so complex? Well because with the user preference domain of com.apple.ncprefs there is a base64 encoded plist embedded in the dnd_prefs key and requires some massaging to get it to a state where PlistBuddy can read and then extract the status from the :userPref:enabled key. See it’s just that easy! 😅

In macOS Monterey and higher Apple is now using JSON for Focus status which is great because it can can be parsed much easier (perhaps using my ljt or jpt tools) but sometimes not even that is needed! Sometimes file presence or awking is sufficient to get a reliable result too. See the evolution of doNotDisturb.sh for macOS for the myriad techniques one can use.

Now, here we are at Monterey on the verge of Ventura and not all areas of macOS have seen the JSON light and are still using nested base64-encoded Plist blobs to store the states of new features like iCloud Private Relay but luckily not the per interface setting of Limit IP Tracking.

iCloud Private Relay Status

Now why would you want to know these statuses? Perhaps you have a VPN product that doesn’t work when Private Relay is turned on or if Limit IP Tracking is turned on for a particular interface and you need to alert the user. Additionally maybe there’s an issue if your VPN browser handshake fails when Safari is used, if so see: Determining URL scheme handlers in macOS. Back to the task at hand though let’s turn some prefs inside out and look at iCloud Relay status with iCloudPrivateRelayStatus.sh

#!/bin/bash
: <<-LICENSE_BLOCK
iCloud Private Relay Status Checker (20250204) - (https://github.com/brunerd)
Copyright (c) 2025 Joel Bruner (https://github.com/brunerd)
Licensed under the MIT License

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.
LICENSE_BLOCK

#############
# FUNCTIONS #
#############

#include this self-contained function in your script
function iCloudPrivateRelay()(

	#only for Moneterey and up, 11 and under need not apply
	[ "$(sw_vers -productVersion | cut -d. -f1)" -le 11 ] && return 1
	
	#parent pref domain
	domain="com.apple.networkserviceproxy"
	#key that contains base64 encoded Plist within parent domain
	key="NSPServiceStatusManagerInfo"
	#path within base64 embedded plist, there are multiple PrivacyProxyServiceStatus entries but this one seems primary
	targetPath=':$objects:1:PrivacyProxyServiceStatus'

	#get the top level data from the main domain
	parentData=$(launchctl asuser "$(stat -f %u /dev/console)" sudo -u "$(stat -f %Su /dev/console)" defaults export "${domain}" -)

	#if domain does not exist, fail
	[ -z "${parentData}" ] && return 1

	#export and decode the base64 data as plist XML and look for 
	keyStatusCF=$(/usr/libexec/PlistBuddy -c "print '${targetPath}'" /dev/stdin 2>/dev/null <<< "$(plutil -extract "${key}" xml1 -o - /dev/stdin <<< "${parentData}" | xmllint --xpath "string(//data)" - | base64 --decode | plutil -convert xml1 - -o -)")
	#if no value, fail
	[ -z "${keyStatusCF}" ] && return 1
	
	#if true/1 it is on, 0/off (value is integer not boolean BTW)
	[ "${keyStatusCF}" = "1" ] && return 0 || return 1
)

########
# MAIN #
########

#example - multi-line if/else calling
if iCloudPrivateRelay; then
	echo "iCloud Private Relay is: ON"
else
	echo "iCloud Private Relay is: OFF"
fi

Even though we work really hard to get the plist data extracted we don’t need to walk the entire XML document (you will find PlistBuddy balks at exporting JSON for a number of reasons). Instead we look for the presence of the key name PrivacyProxyServiceStatus and that is sufficient to reliably detect the state. UPDATE: This ambiguity has been resolved and it will now look at the path :$objects:1:PrivacyProxyServiceStatus for the status, which seems to be consistent (LMK otherwise). I have 2 versions in my GitHub iCloudPrivateRelayStatus.sh and the minified one line version iCloudPrivateRelayStatus.min.sh

Limit IP Tracking Status

Limit IP Tracking, is a per-interface setting that is on by default, however has no effect unless Private Relay is enabled. For this we will use ljt my Little JSON Tool to retrieve the value from the massaged JSON conversion of com.apple.wifi.known-networks.plist if on WiFi or if on Ethernet .../SystemConfiguration/preferences.plist. (Update: Now macOS Sequoia compatible after Apple broke the usual way of getting the SSID name for WiFi connections)

#!/bin/bash
: <<-LICENSE_BLOCK
Limit IP Tracking Status Checker (20250204) - (https://github.com/brunerd)
Copyright (c) 2025 Joel Bruner (https://github.com/brunerd)
Licensed under the MIT License

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.
LICENSE_BLOCK

#############
# FUNCTIONS #
#############

#include this self-contained function in your script
function limitIPTracking()(

	#only for Moneterey and up, 11 and under need not apply
	[ "$(sw_vers -productVersion | cut -d. -f1)" -le 11 ] && return 1

	#Little JSON Tool (ljt) - https://github.com/brunerd/ljt - MIT License
	function ljt () ( #v1.0.8 ljt [query] [file]
	{ set +x; } &> /dev/null; read -r -d '' JSCode <<-'EOT'
	try{var query=decodeURIComponent(escape(arguments[0]));var file=decodeURIComponent(escape(arguments[1]));if(query===".")query="";else if(query[0]==="."&&query[1]==="[")query="$"+query.slice(1);if(query[0]==="/"||query===""){if(/~[^0-1]/g.test(query+" "))throw new SyntaxError("JSON Pointer allows ~0 and ~1 only: "+query);query=query.split("/").slice(1).map(function(f){return"["+JSON.stringify(f.replace(/~1/g,"/").replace(/~0/g,"~"))+"]"}).join("")}else if(query[0]==="$"||query[0]==="."&&query[1]!=="."||query[0]==="["){if(/[^A-Za-z_$\d\.\[\]'"]/.test(query.split("").reverse().join("").replace(/(["'])(.*?)\1(?!\\)/g,"")))throw new Error("Invalid path: "+query);}else query=query.replace("\\.","\udead").split(".").map(function(f){return"["+JSON.stringify(f.replace("\udead","."))+"]"}).join("");if(query[0]==="$")query=query.slice(1);var data=JSON.parse(readFile(file));try{var result=eval("(data)"+query)}catch(e){}}catch(e){printErr(e);quit()}if(result!==undefined)result!==null&&result.constructor===String?print(result):print(JSON.stringify(result,null,2));else printErr("Path not found.")
	EOT
	queryArg="${1}"; fileArg="${2}";jsc=$(find "/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/" -name 'jsc');[ -z "${jsc}" ] && jsc=$(which jsc);[ -f "${queryArg}" -a -z "${fileArg}" ] && fileArg="${queryArg}" && unset queryArg;if [ -f "${fileArg:=/dev/stdin}" ]; then { errOut=$( { { "${jsc}" -e "${JSCode}" -- "${queryArg}" "${fileArg}"; } 1>&3 ; } 2>&1); } 3>&1;else [ -t '0' ] && echo -e "ljt (v1.0.8) - Little JSON Tool (https://github.com/brunerd/ljt)\nUsage: ljt [query] [filepath]\n  [query] is optional and can be JSON Pointer, canonical JSONPath (with or without leading $), or plutil-style keypath\n  [filepath] is optional, input can also be via file redirection, piped input, here doc, or here strings" >/dev/stderr && exit 0; { errOut=$( { { "${jsc}" -e "${JSCode}" -- "${queryArg}" "/dev/stdin" <<< "$(cat)"; } 1>&3 ; } 2>&1); } 3>&1; fi;if [ -n "${errOut}" ]; then /bin/echo "$errOut" >&2; return 1; fi
	)
	
	#sequoia broke the old ways of getting SSID
	function getCurrentWiFiSSIDforInterface(){
		#no interface, error
		[ -z "${1}" ] && return 1

		#get the SSID _quickly_ thanks MacAdmins @jby
		local currrent_SSID=$(ipconfig getsummary "${1}" | awk -F ' SSID : ' '/ SSID : / {print $2}')
		
		[ -z "${currrent_SSID}" ] && return 1
		
		echo "${currrent_SSID}"; return 0
	}

	#test interface specified or fallback to default interface
	interfaceID=${1:-$(route get 0.0.0.0 2>/dev/null | awk '/interface: / {print $2}')}

	#WIFI: key: PrivacyProxyEnabled, file: /Library/Preferences/com.apple.wifi.known-networks.plist
	#if no error getting WiFi SSID, then we are WiFi
	if wifiSSID=$(getCurrentWiFiSSIDforInterface "${interfaceID}"); then

		#key name inside plist
		keyName="wifi.network.ssid.${wifiSSID}"

		#oddly this file is only read-able by root
		if [ ! -r /Library/Preferences/com.apple.wifi.known-networks.plist ]; then
			echo "Insufficient preferences to determine WiFi state, run as root" >/dev/stderr
			exit 1
		fi

		#get JSON version, so much easier to get around, convert date and data types to strings
		wifiKnownNetworks_JSON=$(defaults export /Library/Preferences/com.apple.wifi.known-networks.plist - | sed -e 's,<date>,<string>,g' -e 's,</date>,</string>,g' -e 's,<data>,<string>,g' -e 's,</data>,</string>,g' | plutil -convert json -o - -)
		
		#if there is NO entry, then it is active, it is opt-out designed
		PrivacyProxyEnabled=$(ljt "/wifi.network.ssid.${wifiSSID}/PrivacyProxyEnabled" 2>/dev/null <<< "${wifiKnownNetworks_JSON}")
		
		if [ "${PrivacyProxyEnabled}" = "false" ]; then
			return 1
		fi
	#ETHERNET: key: DisablePrivateRelay, file: /Library/Preferences/SystemConfiguration/preferences.plist, 
	else
		#get JSON, easily converts with not data or date types within
		systemConfigPrefsJSON=$(plutil -convert json -o - /Library/Preferences/SystemConfiguration/preferences.plist)
		#get current set UUID
		currentSet=$(echo "${systemConfigPrefsJSON}" | ljt /CurrentSet 2>/dev/null)
		#get value for current default interface of current Location set
		DisablePrivateRelay=$(ljt "${currentSet}"/Network/Interface/${interfaceID}/DisablePrivateRelay  2>/dev/null <<< "${systemConfigPrefsJSON}")

		#if it is TRUE we are Disabled, then we are NOT ON, return fail code
		if [ "${DisablePrivateRelay}" = "1" ]; then
			return 1
		fi
	fi
)

########
# MAIN #
########

if [ ! -r /Library/Preferences/com.apple.wifi.known-networks.plist ]; then
	echo "Insufficient privileges to determine WiFi state, run as root" >/dev/stderr
	exit 1
fi

#use default interface if nothing is specified for argument $1
interface=${1:-$(route get 0.0.0.0 2>/dev/null | awk '/interface: / {print $2}')}

#returns 0 if ON and 1 if OFF, you may supply an interface or it will fall back to the default
limitIPTracking "${interface}" && echo "Limit IP tracking is ON for: ${interface}" || echo "Limit IP tracking is OFF for: ${interface}"

You can specify an interface or it will use the default interface (there’s a good one-liner to use route get 0.0.0.0 to figure that out). plutil is not initially used to convert com.apple.wifi.known-networks.plist to JSON as it will fail due to XML/plist data types like <date> and <data> that do not have JSON equivalents. First we use sed to change them to <string> types then plutil can convert to JSON. After that it’s a cake walk for ljt to get the value and report back. If the interface is not WiFi then /Library/Preferences/SystemConfiguration/preferences.plist has none of those conversion issues. Check out limitIPTrackingStatus.sh and the minified limitIPTrackingStatus.min.sh at my GitHub.

Thanks for reading! P.S. I won’t be at JNUC 2022, better luck next year!

Decoding macOS automatic login details

In my previous post, Automating automatic login, we looked at how to create the /etc/kcpassword file used for automatic login by using only shell script and built-in command line tools. Why shell only? In preparation for the great scripting runtime deprecation yet to come, I say! Now it’s time to do the reverse for auto login. Let’s get those details back out! Who would need to do such a thing? Imagine a scenario where you the hapless Mac admin have inherited a bunch of Zoom Room Mac minis with auto-login enabled yet no one has documented the passwords used for them! If they are enrolled in Jamf there’s no need to guess what annoying l33t sp3@k password was used, let’s leverage our XOR’ing skills and knowledge of how kcpassword works to send those details back to Jamf.

To get the password back out of /etc/kcpassword we XOR the password again with the same cipher used to obfuscate it originally however but instead of padding it in multiples of 12, we will stop when a character is the same as the current cipher character. FYI when you XOR a value with itself the result is 00 but that’s an unnecessary operation, we can just compare the characters. Voilá, that’s it.

Here’s the gist of the kcpasswordDecode routine:

Now for something a bit more useful to those with Jamf or other management tools: getAutoLogin. It reports the auto login username, if set, and the decodes the /etc/kcpassword file, if present. Note that until macOS 12 Monterey /etc/kcpassword was not removed when Automatic Login was turned off in System Preferences! Here’s what getAutoLogin looks in the Jamf policy logs:

Plaintext passwords in your logs are probably not the best, but hey, how else you gonna figure out your dang Zoom Room passwords? After retrieving the credentials and storing somewhere more secure, like a password manager, make sure to Flush the policy logs! Thanks for reading, I hope this comes in handy or at the very least was informative and mildly entertaining. 🤓

Gist: kcpasswordDecode
Github: getAutoLogin

Automating automatic login for macOS

I recently had some Zoom Room Macs that needed some automation love and I thought I’d share how you can enable Automatic Login via script, perhaps you have several dozen and use Jamf or some other Mac management tool? Interested? Read on! Currently what’s out there are either standalone encoders only or part of larger packaging workflow to create a new user. The standalone encoders lacked some niceties like logging or account password verification and the package method added the required dependency of packaging if any changes were required. Above all, every script required Python, perl or Ruby, which are all on Apple’s hit list for removal in an upcoming OS release. For now macOS Monterey still has all of these runtimes but there will come a day when macOS won’t and will you really want to add 3rd party scripting runtimes and weaken your security by increasing attack surface when you can weaken your security using just shell? 😜 So for some fun, I re-implemented the /etc/kcpassword encoder in shell so it requires only awk, sed, and xxd, all out of the box installs. I also added some bells and whistles too.

Some of the features are:

  • If the username is empty, it will fully disable Automatic Login. Since turning it off via the System Preferences GUI does not remove the /etc/kcpassword file if it has been enabled (!)
  • Ensures the specified user exists
  • Verifies the password is valid for the specified user
  • Can handle blank passwords
  • Works on OS X 10.5+ including macOS 12 Monterey

For the Jamf admin the script is setAutomaticLogin.jamf.sh and for standalone usage get setAutomaticLogin.sh, both take a username and password, in that order and then enable Automatic Login if it all checks out. The difference with the Jamf script is that the first parameter is ${3} versus ${1} for the standalone version.

Also here’s a well commented Gist as a little show and tell for what the shell only version of the kcpassword encoder looks like. Enjoy!

Update: I meant to expound on how it didn’t seem that padding kcpassword to a length multiple of 12 was necessary, since it had been working fine on the versions I was testing with but then I tested on Catalina (thinking of this thread) and was proven wrong by Catalina. I then padded with 0x00 with HexFiend in a successful test but was reminded that bash can’t handle that character in strings, instead I padded with 0x01, which worked on some macOS versions but not others. Finally, I settled on doing on what macOS does somewhat, whereas it pads with the next cipher character then seemingly random data, I pad with the cipher characters for the length of the padding. This works for Catalina and other of macOS versions. 😅

Big Hat Tip to Pico over at MacAdmins Slack for pointing out this issue in pycreateuserpkg where it’s made clear that passwords with a lengths of 12 or multiples thereof, need another 12 characters of padding (or in some newer OSes at least one cipher character terminating the kcpassword data, thanks! 👍

macOS Compatibility Fun!

Compatibility Questions

If you work with Macs and Jamf then you know every year there’s a new per OS Extension Attribute (EA) or Smart Group (SG) recipe to determine if macOS will run on your fleets hardware. However I asked myself: What if a single Extension Attribute script could fill the need, requiring only a periodic updating of Model IDs and the addition of new macOSes?

Then I also asked: Could this same script be re-purposed to output both text and CSV, not just for the script’s running host but for a list of Model IDs? And the answer was a resounding yes on all fronts!

EA Answers

So, my fellow Jamf admin I present to you macOSCompatibility.sh in its simplest form you just run the script and it will provide ultra-sparse EA output like: <result>10.14 10.15 11</result> this could then be used as a Smart Group criteria. Something like “macOS Catalina Compatible” would then match all Macs using LIKE 10.15 or “Big Sur Incompatible” would use NOT LIKE 11, of course care would be taken if you were also testing for 10.11 compatibility, however the versionsToCheck variable in the script can limit the default range to something useful and speeds things up the less version there are. I hope this helps Jamf admins who have vast unwieldy fleets where hardware can vary wildly across regions or departments,

CSV Answers

Now if you provide a couple arguments like so: ./macOSCompatibility.sh -c -v ALL ALL > ~/Desktop/macOSCompatibilityMatrix.csv you will get a pretty spiffy CSV that let’s you visualize which Mac models over the years have enjoyed the most and least macOS compatibility. This is my favorite mode, you can use it to assess the OS coverage of past Macs.

See macOSCompatibilityMatrix.csv for an example of the output. If you bring that CSV into Numbers or Excel you can surely liven it up with some Conditional Formatting! This is the barest of examples:

Can you spot the worst and best values?

Text Answers

If you don’t use the -c flag then it’ll just output in plain or text, like so: ./macOSCompatibility.sh -v ALL ALL

iMacPro1,1: 10.13 10.14 10.15 11
MacBook1,1: 10.4 10.5 10.6
MacBook2,1: 10.4 10.5 10.6 10.7
MacBook3,1: 10.5 10.6 10.7
MacBook4,1: 10.5 10.6 10.7
MacBook5,1: 10.5 10.6 10.7 10.8 10.9 10.10 10.11
MacBook6,1: 10.6 10.7 10.8 10.9 10.10 10.11 10.12 10.13
MacBook7,1: 10.6 10.7 10.8 10.9 10.10 10.11 10.12 10.13
MacBook8,1: 10.10 10.11 10.12 10.13 10.14 10.15 11
MacBook9,1: 10.11 10.12 10.13 10.14 10.15 11
MacBook10,1: 10.12 10.13 10.14 10.15 11
MacBookAir1,1: 10.5 10.6 10.7
MacBookAir2,1: 10.5 10.6 10.7 10.8 10.9 10.10 10.11

Wrapping Up

Now, it’s not totally perfect since some models shared Model IDs (2012 Retina and Non-Retina MacBook Pros for example) but for the most part the Intel Mac Model IDs were sane compared to the PPC hardware Model IDs: abrupt jumps, overlaps, and re-use across model familes. Blech! I’m glad Apple “got religion” for Model IDs (for the most part) when Intel CPUs came along. I did attempt to go back to 10.1-10.3 with PPC hardware but it was such a mess it wasn’t worth it. However testing Intel, Apple Silicon and VMs against macOS 10.4 – 11+ seems to have some real use and perhaps you think so too? Thanks for reading!