Books Update — 2025

It’s been a while since I wrote about my books. Life has been tugging me in different directions (in a good way, overall). Things are going well, overall, but there was always this nagging feeling that I really should do something about the books. They were getting a bit… well… old…

If you follow this blog, you may have noticed a few posts about packaging recently:

If you are a proud owner of my book “Packaging for Apple Administrators” (thank you very much!) these posts should seem somewhat familiar. It has been nearly nine years since I first published “Packaging” and even though it really held up well, it was in desperate need of some updates. More than merely updates, really. Many of the examples not available online anymore. Seriously, some of the examples are to inspect the iTunes and Silverlight installer pkg…

Surprisingly little has changed in the process of actually building packages, so those sections of the book hold up pretty well. But the environment in which packages are used and deployed on macOS has changed. Quite a lot. GateKeeper and Notarization were new and optional, just a few years ago, but now are a core part of Apple’s security strategy on macOS. Bundle package installers, which I covered in a “legacy” appendix in “Packaging” were completely disabled in macOS Sequoia 15. Imaging Macs with NetInstall was still a thing when I originally wrote the book and how to use and prepare installer packages for those workflows took up some space.

Distribution packages were only required in edge cases for Mac admins. Now they are often (but not always) required to work with device management servers.

On the other hand, back then I did not have any experience with the developer side of packaging. Since then I have written about building tools and apps and integrating the packaging (and signing and notarization workflows) in Xcode and Swift Package Manager. These are workflows that are useful to developers, but less so for Mac Admins.

So, I am happy to announce that I have started the work of updating “Packaging.” It’s a work in progress, and I do not want to commit to any timeline yet. However, I plan to continue to share the progress by posting sections on this blog as I update them.

What will happen to old, outdated “Packaging for Apple Administrators” you might ask? Well, I am going to remove the book from Apple Books in two weeks or so. If you really want to own a copy of this old version, this will be your last chance to purchase it. I didn’t want to remove the book without warning. But, honestly, most of you really don’t have to buy the old version anymore, since I will be posting parts as I update and rewrite.

(If you want to buy a copy to support me, don’t do that on Apple Books. The standard 30% of that revenue will go straight to Apple and honestly, they have enough money. There is now a better way, but more on that later.)

In two or three weeks time, I will remove all books, except “macOS Terminal and Shell” from Apple Books.

If you have purchased the book, it should remain available for you in your library, but maybe make a backup to be sure.

I have spent a few days updating “macOS Terminal and Shell” for the current state of macOS. Since this is my latest book and well, the command line situation hasn’t changed very much since Apple switched the default shell to zsh, there wasn’t much to change. I will keep that book on Apple Books and update it as soon as the new version passes Apple’s review. If you have already purchased “macOS Terminal and Shell” you should get the updated version as soon as I have uploaded it. You should then see a notification in the Books app.

I am also starting a new experiment: you can also purchase “macOS Terminal and Shell” on Ko-Fi. (might be more familiar as “Buy me a Coffee“) This is an experiment and new to me, so apologies if there are some rough edges. This should work if you do not want to or cannot purchase on Apple Books.

Also, I get a larger share of the proceeds. And, should you desire to, you can even pay more than the suggested price. (though, really, no-one has to)

As I said, this is a test run, and I am very curious how it goes. I am excited that this should expand the audience for whom the book is available. (Apple Books is not available in many regions, like India and China.) If the experiment works out for this update of “Terminal and Shell” then I will definitely consider this for “Packaging 2.0” and future books, as well. (I have plenty ideas, but so little time)

Building Simple Component Packages

Building packages can turn complex very quickly. We will start with a few simple examples first and build up from there.

You should read the first two posts in this series, first:

Boring Wallpaper

For our first simple example, imagine your organization insists that every Mac use the same wallpaper (or desktop picture). The first step for an admin is to provide the image file on each Mac.

Apple changed the naming for the background image in macOS Ventura from ‘desktop picture’ to ‘wallpaper’ in macOS Sonoma to match the naming across all their platforms. As we will see, the old name still appears in some places.

Technically, an image file used as a wallpaper image could be stored anywhere on the disk. When you place image files in /Library/Desktop Pictures/ (still the old name) a user will see them as a choice among the pictures in the ‘Wallpaper’ pane in System Settings. They will have to scroll all the way to the right in the ‘Dynamic Wallpapers’ or ‘Pictures’ section but the images you add in that folder will appear there.

The /Library/Desktop Pictures folder does not exist on a ‘clean’ installation of macOS, but the installation package will create it for us.

All the resources for building the package have to be provided in a certain folder structure. It is easiest to contain all of that in a single project folder.

I will provide the instructions to create and build the projects in the command line as they are easier to represent than descriptions of how to achieve something in the graphical user interface. It does not really matter whether you create a folder from Terminal or in Finder. Do what you are comfortable with. However, Getting comfortable with command line tools in Terminal is an important step towards automated package creation workflows.

My book “macOS Terminal and Shell” can teach you the fundamentals. Buy it on Ko-Fi or Apple Books!

I often find it helpful to have a Finder window for the current working directory open next to Terminal. You can open the current working directory in Finder with the open . command.

Create a folder for the project named BoringWallpaper in a location of your choosing.

> mkdir BoringWallpaper
> cd BoringWallpaper

You can download the sample image file I will be using here. Of course you can use another wallpaper image of your choice.

The BoringWallpaper folder will be our project folder. Create another folder inside BoringWallpaper named payload. Inside the payload folder, we will re-create the path to where we want the file be installed in the file system, in this case /Library/Desktop Pictures.

Since the Desktop Pictures folder name contains a space, we need to quote the path in the shell and scripts. You can also escape the space with a backslash \. The effect will be the same. In general, I recommend using tab-completion in the command line for file paths which will take care of special characters.

We can create the entire folder structure at once with mkdir -p:

> mkdir -p "payload/Library/Desktop Pictures"

Then copy the first desktop picture BoringBlueDesktop.png into payload.

> cp /path/to/BoringBlueDesktop.png "payload/Library/Desktop Pictures"

The payload folder will gather all the files we want the package to install.

Your folder structure inside the BoringWallpaper project folder should now look like this:

📁 payload
    📁 Library
        📁 Desktop Pictures
            📄 BoringBlueDesktop.png

The payload folder represents the root of the target volume during installation.This will usually be the root of the system volume /. We recreated the folder structure where we want the file to be installed in the file system. The installer will create intermediate folders that do not yet exist during installation.

In this example, the /Library folder will already exist, but the Desktop Pictures subfolder will not yet exist on a clean system, so it will be created and the image file will be placed inside.

When you run the installer on a system where the Desktop Pictures subfolder already exists, the image will be placed inside that. Should a file with the same name already exist in that location, it will be overwritten. Other files that might be in that folder will generally not be affected.

Introducing pkgbuild

macOS provides the pkgbuild command line tool to create installer package components. Make sure your current working directory is BoringWallpaper and run

> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg

The --root option designates the root of our payload, so we pass the payload folder.

The identifier should be a string modeled on the reverse DNS of your organization, e.g. com.scriptingosx.itservices.BoringWallpaper. The exact form of the identifier does not really matter as long as it is unique.

We will use com.example.BoringWallpaper for the identifier. For the exercise, you can use those or replace them with your own. When you build packages for production or deployment they should use your organization’s reverse DNS format.

This should create a BoringWallpaper.pkg file in your project folder.

You should now inspect the resulting pkg file with the tools from earlier:

> lsbom $(pkgutil --bom BoringWallpaper.pkg )
.    40755   0/0
./Library    40755   0/0
./Library/Desktop Pictures    40755   0/0
./Library/Desktop Pictures/BoringBlueDesktop.png    100644  0/0 17393342618871431

(You may see a ._BoringBlueDesktop.png file appear here. That is a resource fork file. Preview.app sometimes creates these for file previews. You can safely ignore those.)

There are two relevant things to notice here: the payload contains the intermediate folders /Library and /Library/Desktop Pictures, which means they will be created, should they not exist on the system yet. This is generally what we want to happen, but a good thing to keep in mind.

Also notice that pkgbuild set the owner and group ID for the folders and the image file to 0/0 or root:wheel. This is the default ownership for files installed by packages, which ensures that non-admin users cannot change, delete or overwrite the files. This is a useful default, but there are options to have more granular control.

pkgbuild will always preserve the file mode or access privileges. When you change the file mode in of the file in the payload folder before you run pkgbuild, the command line tool will use that for the payload and Bom. In this case, the 644 or r-wr--r-- file mode works quite well, but for a test, let’s change the mode to 444 (removing the write access for the owner) and re-run the pkgbuild command.

> chmod u-w payload/BoringBlueDesktop.png 
> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg
pkgbuild: Inferring bundle components from contents of payload
pkgbuild: Wrote package to BoringWallpaper.pkg
> lsbom $(pkgutil --bom BoringWallpaper.pkg )
.    40755   0/0
./Library    40755   0/0
./Library/Desktop Pictures    40755   0/0
./Library/Desktop Pictures/BoringBlueDesktop.png    100644  0/0 17393342618871431

Note that running the pkgbuild command again overwrote the previously generated pkg file with warning. This is generally not a problem, but something you need to be aware of.

We will want to change the image file in the following steps, so add the writable flag back:

> chmod u+w "payload/Library/Desktop Pictures/BoringBlueDesktop.png"

Handling extended attributes

In recent versions of macOS, pkgbuild will preserve extended file attributes in the payload.

This is a change in behavior to earlier versions of macOS, where you had to use the undocumented --preserve-xattr option to preserve extended attributes.

Most extended attributes contain metadata for Finder and Spotlight. For example, when you open the image file in Preview, you will get a com.apple.lastuseddate#PS extended attribute. You can use the -@ option of the ls command to see extended attributes:

> open "payload/Library/Desktop Pictures/BoringBlueDesktop.png"
> ls -l@ "payload/Library/Desktop Pictures"
total 3400
-rw-r--r--@ 1 armin  staff  1739334 Aug  1 14:03 BoringBlueDesktop.png
    com.apple.lastuseddate#PS        16 

You generally do not want to have extended attributes be part of your package payload. This is especially true of quarantine flags!

There are some exceptions. For example, signed shell scripts store the signature information in an extended attribute. In these cases you will have to carefully build your package creation workflow to ensure only the desired extended attributes are preserved in the package and installed to the target file system.

You can remove extended attributes recursively with the xattr command:

> xattr -cr payload

Then rebuild the package:

> pkgbuild --root payload --install-location / --identifier com.example.BoringWallpaper --version 1 BoringWallpaper.pkg
pkgbuild: Inferring bundle components from contents of payload
pkgbuild: Wrote package to BoringWallpaper.pkg

Creating a Build Script

The command line tools to create installer package files have a large number of options. Even in our simple example, pkgbuild requires several options and arguments. Each one needs to be entered correctly so the installer process does the right thing. An error in the identifier or a version number will result in unexpected behavior that may be very hard to track down. In addition, there are steps like running xattr -c that need to be performed before creating the package.

To avoid errors and simplify the process, we will create a shell script which runs the required commands with the correct options. The script will always repeat the commands with the proper arguments in the correct order, reducing the potential for errors. Updating the version is as simple as changing the variable in the script.

In your favored text editor create a file named buildBoringWallpaperPkg.sh and with the following code:

#!/bin/sh

pkgname="BoringWallpaper"
version="1.0"
install_location="/"
identifier="com.example.${pkgname}"

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

projectfolder=$(dirname "$0")
payloadfolder="${projectfolder}/payload"

# recursively clear all extended attributes
xattr -cr "${payloadfolder}"

# build the component
pkgbuild --root "${payloadfolder}" \
         --identifier "${identifier}" \
         --version "${version}" \
         --install-location "${install_location}" \
         "${projectfolder}/${pkgname}-${version}.pkg"

The script first stores all the required pieces of information in variables. This way you can quickly find and change data in one place and do not have to search through the entire script.

The identifier variable is composed from our com.example reverse-DNS prefix and the pkgname variable set earlier.

Then the script sets the shell PATH variable, which is always a prudent step.

In the next line, the script determines the folder enclosing the script, by reading the $0 argument which contains the path to the script itself and applying the dirname command which will return the enclosing folder. This way we can use the projectfolder variable later to write the resulting pkg file into a fixed location (the project folder), instead of the current working directory.

Finally the pkgbuild command is assembled from all the variables.

The backslash \ in a shell script allows a command to continue in the next line. Instead of a command in a single very long line we can have one line per argument. This makes the script easier to read and update.

In Terminal, set the script file’s executable bit with

> chmod +x buildBoringWallpaperPkg.sh

Delete the original BoringWallpaper.pkg and run the build script.

> rm BoringWallpaper.pkg
> ./buildBoringWallpaperPkg.sh
pkgbuild: Inferring bundle components from contents of ./payload
pkgbuild: Wrote package to ./BoringWallpaper-1.0.pkg 

This will create a new package file named BoringWallpaper-1.0.pkg.

Now you do not have to remember every option exactly but instead can run ./buildBoringDesktopPkg.sh. If you need to change options like the version number, it is easy to do by changing a variable. Package creation is easy to repeat and if you use a version control system (e.g. git) changes to the script are tracked.

You have literally codified the package creation process. If you are working in a team, you can point to the script and say: “This is how we create this package!” and everyone who has access to the script can recreate the package creation workflow. They can also read the script and understand what is going to happen. A script like this does not replace the need for documentation, but is better than no documentation at all.

To be precise, the build script and the files and folder structure in the payload are required together for the package creation workflow. They should be kept and archived together.

Ideally together with documentation describing:

  • motivation/reason for building this package
  • where and how the package is intended to be used
  • whether the software installed requires certain configurations that are not provided by the pkg, such as licenses or default settings through a script or a configuration profile and where and how to obtain and set those
  • macOS and platforms (Intel or Apple silicon) the package was built on
  • macOS versions and platforms the package was tested on
  • where to obtain the resources in the payload, should they get lost or need to be updated
  • person(s) or team responsible for this package project
  • version history or change log
  • an archive of older versions of the pkg file
  • uninstall process or script
  • any other relevant links and history, for example problems and issues that lead to certain design choices

For developers, scripts like this can be part of an automated release or CI/CD workflow.

You do not have to include the version number in the package name, but it helps in many situations. It also helps when you build an archive or history of installers. You never know when you will need an older version of an installer. When vendors/developers provide the version number in their file names, it helps admins and users identify new or outdated versions.

You should (again) inspect the new package file you just created in Suspicious Package and using pkgutil.

Testing the Package

Finally, you can install this package on your test machine.

For this simple package, you can also use the Mac you are currently working on. With more complex packages, especially when we get into installation scripts and LaunchD configuration, a virtual machine or separate testing device is strongly recommended.

When you run the package in the Installer application, note that the dialogs are the defaults and very terse. System administrators will rarely build packages that are meant to be installed with the user interface, so this is not a problem. Most administrative package files will be installed by management systems in the background and never show the UI.

Developers can customize the user interface for installer packages with distribution files, we will get to in a future post.

You can also use the installer command to install the package:

> sudo installer -pkg BoringWallpaper-1.0.pkg -tgt / -verbose
installer: Package name is BoringWallpaper-1.0
installer: Installing at base path /
installer: The install was successful.

After installing, go to /Library/Desktop Pictures and look for the BoringBlueDesktop.png image file, then open System Settings and go “Wallpaper.” You will have to scroll down to the “Pictures” section and all the way to the right, but the picture will appear there!

You can also open Terminal and run the pkgutil commands to inspect what was installed: (replace com.example.* with your identifier)

> pkgutil --pkgs="com.example.*"         
com.example.BoringWallpaper

> pkgutil --info com.example.BoringWallpaper
package-id: com.example.BoringWallpaper
version: 1.0
volume: /
location: 
install-time: 1754058568

> pkgutil --files com.example.BoringWallpaper
BoringBlueDesktop.png

> pkgutil --file-info /Library/Desktop\ Pictures/BoringBlueDesktop.png                        
volume: /
path: /Library/Desktop Pictures/BoringBlueDesktop.png

pkgid: com.example.BoringWallpaper
pkg-version: 1.0
install-time: 1754058568
uid: 0
gid: 0
mode: 100644

If you created an installer package that attempted to install the image file in /System/Library/Desktop Pictures, it would fail. This directory is protected by two technologies, System Integrity Protection and the read-only sealed System volume. Figuring out the proper location to install management files is an important, but sometimes complicated task.

Removing the installed files

If you tested this package on your main Mac, you can remove the installed files with the following commands:

> sudo rm /Library/Desktop\ Pictures/BoringBlueDesktop.png
> sudo rmdir /Library/Desktop\ Pictures
> sudo pkgutil --forget com.example.BoringWallpaper

Note the rmdir command will error when there are other files remaining in it. This is intentional here, since we only want to remove the folder if it is empty and not remove files other than those we installed. The /Library folder is part of the base macOS installation, so we are not going to touch it.

Similar to the build script, it can be useful to maintain the un-install script while developing the package, especially for more complex installs. An un-install script for this project might look like this:

#!/bin/sh

# uninstall Boring Wallpaper

# reverts the installation of com.example.BoringWallpaper


# remove the file
rm -vf "/Library/Desktop Pictures/BoringBlueDesktop.png"

# remove folder
# fails when there are other files remaining inside
# we do not want to affect files we did not install
rmdir -v "/Library/Desktop Pictures"

# forget the pkg receipt
pkgutil --forget com.example.BoringWallpaper

Since the installed file and folder are owned by root, you need to run the entire script with root privileges or sudo:

> sudo ./uninstallBoringWallpaper.sh

Note that many device management services offer the option to run scripts on the Mac clients and they generally run the scripts with root privileges. Consult your management service’s documentation for details.

You have to remember to update the uninstall script when you change the payload and other settings in the package. This will be useful for your testing.

Developers or vendors can also provide the uninstall script to customers in case they need to uninstall the software. Administrators could use the uninstall script in a self service portal to allow end users to remove software they no longer require.

Note that software might create other files while it is running that are not part of the installer package. Use your judgment whether they need to be removed as part of the uninstall script. Some files might contain user data that should be preserved, even when the software is deleted.

Installing Packages

What is a package?

If you have been using a Mac, you will have encountered an installation package file or pkg.

Package files are used to install software and configuration files on your Mac.

Package files come in different flavors and formats, but they can be summarized to these relevant components:

  • a payload
  • installation scripts

A package file may have only a payload, only scripts, or both.

The payload is an archive of all the files that the package will install on the system. The package also contains a “bill of materials” (BOM) which lists where each file should be installed and what the file privileges or mode should be.

Installation scripts can be executed before and after the payload is installed.

Additionally, packages contain some metadata, which provides extra information about the package and its contents. They can also contain images and text files, such as license agreements that can customize the default Installer app interface.

Installer application

The most common way of installing a package file and start its installation process, is to open it with a double-click. This opens the default application for the pkg extension: Installer. The Installer app can be found in /System/Library/CoreServices/. However, you rarely need to open it directly. It is usually started indirectly by opening a package file.

After launch, the Installer app presents different panels to the user. The exact order and content of the panels depends on what the developer of the package configured. At the very least, it will show:

  • a short introduction
  • prompt to authenticate for administrative privileges
  • a progress bar of the installation
  • whether the installation succeeded or failed

A package may also show:

  • a custom background image
  • a detailed introduction
  • a license agreement that needs to be accepted
  • alternative installation location
  • options to select certain subsets of files and apps (components)
  • more custom steps implemented by the developer

Installer app can also list the contents of a package file without installing it first. You can choose ‘Show Files…’ (⌘I) from the ‘File’ menu to get a list of files in the payload. This list can be very extensive and there is a search field to filter the list.

If you want to see more than just the progress bar during the installation, you can select “Installer Log” from the “Window” menu (⌘L) and then increase the output to “Show all Errors and Logs” (⌘3).

You can also review the installation log afterwards, by opening the Console application from Utilities and choosing “install.log” under “Log Reports.” You can also look at /var/log/install.log directly.

This log is notoriously verbose. There will be many entries for each installation and some related system services like software update will log here, too.

Security

Packages can place files and executables in privileged locations in the file system. When you open a package file with the installer application, it will usually prompt for administrator privileges.

In the early days of Mac OS X, package installers had no limitations at all. However, these days, digital security and privacy are important features and criteria for platforms and Apple has implemented several features in macOS which set strong limits on what third-party package installers can do.

Most importantly, System Integrity Protection (SIP, introduced in OS X Yosemite 10.11) and the Signed System Volume (introduced in macOS Catalina 10.15) prevent third-party packages from affecting system files.

Even with these protections in place, packages still provide many options for abuse. Packages are very useful to install legitimate software but can also be used to install and persist malicious tools.

File Quarantine

File quarantine does not directly limit the abilities of package installers, but it is important to understand how it works together with other security features in macOS.

Quarantine flags were introduced in Mac OS X as early as version 10.4 (Tiger) but were not actually used for much until later in 10.5 (Leopard).

When a file arrives on your Mac from an external source, such as a download in a web browser, an email attachment, or an external drive, the process that downloads or copies generally adds a quarantine flag. The quarantine flag is added in an extended attribute to the file.

Note: the examples here reference desktoppr-0.5-218.pkg, the installer for a open source command line tool I wrote. You can download its installer pkg from the GitHub repository. The version and build number might be different.

After the download, you can see some of the metadata added to the download the Finder info window. Select the downloaded pkg file and choose ‘Get Info’ (⌘I) from the context or ‘File’ Menu. In the Info window that appears, you need to expand the ‘More Info’ section, where you will see the URL it was downloaded from. There might be more than one URL if the browser was redirected.

You can get more information in the command line using the xattr command line tool: (xattr stands for ‘extended attribute’, it is often pronounced like ‘shatter’)

> xattr desktoppr-0.5-218.pkg         
com.apple.metadata:kMDItemDownloadedDate
com.apple.metadata:kMDItemWhereFroms
com.apple.quarantine

The quarantine flag has the attribute name com.apple.quarantine. You can also use xattr to show the contents of the extended attribute:

> xattr -p com.apple.quarantine desktoppr-0.5-218.pkg
0083;6842fcbe;Safari;A2964F09-ACDF-430F-8CFF-48BD75C464CD

The contents are (in order, separated by semi-colons):

  • a hexadecimal flag number: always 0083
  • a hexadecimal timestamp: e.g. 6842fcbe
  • the process that created the file: e.g. Safari
  • a universal identifier (UUID)

You can convert the hexadecimal timestamp into a human readable time with

> date -jf %s $((16#6842fcbe)) 
Fri Jun  6 16:35:42 CEST 2025

There are two more extended attributes attached to the downloaded file that are interesting. The awkwardly named com.apple.metadata:kMDItemDownloadedDate and com.apple.metadata:kMDItemWhereFroms contain the download date and the web addresses the file was downloaded from respectively. When you look at them withxattr, you see the data is stored in a binary property list format.

> xattr -p com.apple.metadata:kMDItemDownloadedDate desktoppr-0.5-218.pkg
bplist00?3A????r

To show this in a human readable format, you have to pipe the output through xxd and plutil:

> xattr -x -p com.apple.metadata:kMDItemDownloadedDate desktoppr-0.5-218.pkg | xxd -r -p | plutil -p -
[
  0 => 2025-06-06 14:35:42 +0000
]

The name of the extended attribute gives us a hint that the information is also accessible through the file’s metadata, which is a bit easier:

> mdls -name kMDItemDownloadedDate desktoppr-0.5-218.pkg
kMDItemDownloadedDate = (
    "2025-06-06 14:35:42 +0000"
)

The named kMDItemWhereFroms attribute contains the URLs the file was downloaded from. It might be more than one URL because of redirections.

The URLs for GitHub downloads tend to be very long. After manually downloading Firefox, I see these URLs:

> mdls -name kMDItemWhereFroms Firefox\ 139.0.1.dmg 
kMDItemWhereFroms = (
    "https://download-installer.cdn.mozilla.net/pub/firefox/releases/139.0.1/mac/en-US/Firefox%20139.0.1.dmg",
    "https://www.mozilla.org/"
)

It is important to remember that not all processes add quarantine flags to downloaded or copied files. As a general rule, applications with a user interface add quarantine files and command line tools and background processes do not. But there may be exceptions either way.

For example, when you download a file using the curl command line tool, it will have no quarantine flag or other metadata extended attributes. This is might be exploited by malicious software.

The quarantine flag serves as a signal to the system that this file or archive needs to be checked before opening or running. The part of the system that performs this check is called Gatekeeper.

Gatekeeper

Gatekeeper was introduced to macOS in OS X Lion (10.7) in 2011. Gatekeeper will verify the integrity, signature and notarization status of an app or executable before opening it.

A package installer file can be:

  • not signed at all
  • signed, but not notarized
  • signed and notarized

Gatekeeper works somewhat differently for installer packages compared to applications that you download in disk image (dmg) or other archive formats (e.g. zip). When you copy an application out of a disk image into the Applications folder or unarchive it from an archive, the system transfers the quarantine flag and metadata to the application bundle on the local disk. When you then open the app for the first time, the presence of the quarantine flag signals Gatekeeper to verify the integrity of the application using the signature and verify the notarization status. You can see the dialogs the system might present in this Apple support document.

The signature verifies the integrity and the source of an application or installer package. With an intact signature, you can be certain the package file has not been modified since it was signed. If the signature uses an Apple Developer ID, you can be fairly certain that the package was signed by that developer, or someone from that organization. There have been cases where Apple Developer ID certificates were stolen, but Apple will usually invalidate those fairly quickly.

Notarization is an extra step where the developer uploads the signed package file to Apple’s notarization servers. Apple then scans the package for certain security features and whether known malware signatures are present. Apple then adds the package file’s hash to their notarization database. The developer can also ‘staple a ticket’ to the package file, so that Gatekeeper doesn’t have to reach out to Apple’s servers on the check.

When the Gatekeeper verification of the signature and notarization status succeeds, the user gets a prompt to confirm that they want to launch the application they just downloaded.

When either verification fails, most commonly because the app is not notarized, the user gets a different, quite scary, prompt, stating that the system cannot verify the package is free of malware. You will get the same dialog when the package is not signed at all.

While generally, all software developers should sign and notarize their packages, this is not always the case. Open source projects, often have neither the financial nor logistical means to obtain an Apple Developer account, which provides the required Apple Developer ID certificates.

Bypassing Gatekeeper

Before you choose to install a package file which is not validly signed or notarized, you should first be certain the source is trustworthy. Then you should probably inspect the package using the tools in this post to verify that it only installs what it is supposed to, before actually installing it.

After attempting to open the pkg file with the Installer app by double-clicking and receiving the dialog that it could not be verified, click ‘Done.’ Then navigate to the ‘Privacy & Security’ pane in System Settings. Under the ‘Security’ section, you will see a message that the package ‘was blocked to protect your Mac’ with a button to ‘Open Anyway.’

This extra option will disappear after a few minutes.

You can also remove the quarantine flag using the command line.

> xattr -d com.apple.quarantine desktoppr-0.1.pkg

Without the quarantine flag, the Gatekeeper verification will not be triggered when the file is opened.

Packages installed by a device management service are not checked by Gatekeeper and do not need to be notarized. With some services, the packages may need to be signed, but not necessarily with an Apple Developer ID. Consult the documentation of your device management service for details.

Installer command line tool

You can also install package files from the command line using the installer tool.

To install a package file on the current system volume, use the installer command like this

> sudo installer -package desktoppr-0.5-218.pkg -target /

There are shorter flags for both these options:

> sudo installer -pkg desktoppr-0.5-218.pkg -tgt /

Installing a package with the installer command will not enforce a restart or logout, whether the package requires one or not. You will have to perform or schedule the reboot manually.

Installing with installer will also not trigger GateKeeper checks, whether the quarantine flag is set or not.

The -verbose flag will increase the output of the installer tool which can help when you need to analyze problems. The installer process will also log to /var/log/install.log so you can also monitor or review the installation log in the Console app.

Run a script when Setup Manager is finished

You may have heard, we built an enrollment tool called Setup Manager for Jamf Pro and Jamf School.

As with any new tool, there are some things that it doesn’t do yet and there are some conflicts with other software that we hadn’t anticipated.

We are working on these things, but it takes time. Until then, I thought it might be useful if there were a way to trigger you own scripts when Setup Manager is finished with its workflow. This wasn’t too hard to put together and I can already see a few useful applications.

How it works

You can use LaunchDaemons to run your own scripts and tools in the background. There are many different triggers you can use to control when your LaunchDaemon launches. One option is a WatchPath which triggers when a file at a certain path is created, deleted or modified.

Setup Manager creates a flag file at /private/var/db/.JamfSetupEnrollmentDone when it completes successfully. We can use this file as a watch path to launch our own script: setupManagerFinished.sh.

In the script, we first set a few useful variables. Since the WatchPath LaunchDaemon triggers on creation, deletion and modification, the script checks first whether the flag file exists, otherwise it just exits without doing anything.

Then the script waits for the jamf binary to not be running anymore. With Jamf Pro, when Setup Manager finishes up, it creates the flag file and then runs its final recon/inventory update. It has to do it in this order, so that an extension attribute that is checking for the flag file gets updated correctly. That means that our script will be triggered before Setup Manager is actually really done. But we can be fairly sure that when the jamf process is done, then Setup Manager has really finished its workflow.

Setup Manager might be still displaying its UI here, but the work is done. (If you want to defer launching until Setup Manager itself quits, you can replace jamf with "Setup Manager" (quotes are important) in line 30.)

Then, we get to the point where we run what we actually want to do. In the sample project, the script runs the jamf binary with a custom trigger. All policies in scope with that custom trigger will be executed. You can even attach a restart action to the last of those policies to force a reboot at this point.

SetupManager and this LaunchDaemon can run with Jamf School, as well. In that case, you wouldn’t run the jamf binary since that is exclusive to Jamf Pro. But you can modify the script to run other commands here to finish the setup.

Chicken, meet egg

You can do additional installations using Installomator, especially for tools that might interfere with Setup Manager. You can send notifications to Slack or Teams with data from the completed Setup Manager workflow.

You can also force a restart here with the shutdown -r +5s command. (The five second delay gives the LaunchDaemon script a chance to clean up and exit.)

Authentication tools, like Jamf Connect or XCreds, need to restart the login window so that their software can load correctly. If you are using the option to run Setup Manager at the login window, this will quit and then restart Setup Manager, too.

Some tools and apps have installation workflows that simply don’t work well with Setup Manager for other reasons.

That means you want to avoid installing these tools with Setup Manager directly, but you can install them after Setup Manager is done.

With this LaunchDaemon, you can run all kinds of additional workflows and install troublesome tools, either with a Jamf Pro policy or Installomator.

Usage

Download the repo and adapt the setupManagerFinished.sh script to your needs. The code you want to adapt is in lines 39 through 47.

The default script triggers a custom policy trigger (setup_manager_finished, defined in line 10)

The buildSetupManagerFinishedPkg.sh script will assemble the LaunchDaemon plist, the script and the installation scripts into a pkg.

You will have to adapt the name of the certificate you use to sign the pkg in line 20. If you do not have a signing certificate, you can set signature="" and the script will build an un-signed pkg. You need a signed installer pkg when you want to add it to the Jamf Pro Prestage or install it with Jamf School. When the pkg is not signed, you can still install it with a Jamf Pro policy with a Setup Manager action. As long as it is installed before Setup Manager finishes, it will trigger when the flag file gets created.

Conclusion

You can find the code on the repository. I am curious what workflows y’all are going to build with this! Let us know in the Mac Admins Slack in the #jamf-setup-manager channel!

Building a LaunchD Installer pkg for desktoppr (and other tools)

In my last post introducing desktoppr 0.5, I said that a Mac admin could build a customized pkg with a customized LaunchAgent property list, that would run desktoppr manage at login and every few hours in the background to re-set the wallpaper when the configuration profile is updated.

I admit I glossed over the process of building a package that installs and launches a LaunchAgent (or LaunchDaemon). Since that is not a trivial configuration, I will describe the details in this post.

LaunchAgent? LaunchDaemon? LaunchD?

On macOS, launchd is the system-wide process that controls and launches every other process running on the system. When you look at the hierarchical process list in Activity Monitor, you will always see launchd running with the process ID 1 as the single child process of kernel_task and all other processes are a child of launchd.

More practically, users and admins can use launchd to control processes that need to be available at all times or be launched in certain intervals or at certain events.

launchd has two types of processes: LaunchDaemons and LaunchAgents. Since the terms daemons (or demons) and agents do not have clear definitions outside of launchd, a bit of explanation is required.

LaunchDaemons

LaunchDaemons are processes that run with system privileges (“as root”). Processes that belong to your management system and security suites may need to run periodically or on certain events in the background and require root level access to the system to do their jobs. Other processes or services need to listen for incoming network traffic on privileged ports.

Note that background process are often called ‘agents’ regardless of whether they run with root privileges or as a user. The launchd terminology is more precise here.

Apple has done a lot of work over the past few year to provide Endpoint Security or Network Extension frameworks that mitigate the need for third party root daemons, but there are still relevant use cases, especially when you are a managing Macs.

A LaunchDaemon is configured by providing a property list file in /Library/LaunchDaemons. This configuration plist will be automatically loaded at reboot, or can be loaded using the launchctl command.

LaunchAgents

LaunchAgents are processes that run with user privileges. Generally, if something needs to read or write to anything in the user home folder, it should be a LaunchAgent. Over the recent years, Apple has put restrictions on what data process can access in user space, which has made LaunchAgents more of a pain to manage, but they still have their use cases.

A LaunchAgent is configured by providing a property list file in /Library/LaunchAgents on the system level or in ~/Library/LaunchAgents in a user’s home folder. Configuration property lists that are in a user home folder, will only run for that user, while LaunchAgents that are configured in the system wide /Library/LaunchAgents will be loaded for all users on the system.

A LaunchAgent configuration plist will be automatically loaded at login, or can be loaded using the launchctl command.

Since desktoppr affects a user setting (the desktop/wallpaper), it has to run as the user, so we need to build and install a LaunchAgent. Creating and installing a LaunchDaemon is pretty much the same process, though.

Ingredients

To build an installation package to install and run a LaunchAgent or LaunchDaemon, you need three ingredients:

  • the binary or script which will be launched
  • a launchd configuration property list
  • installation scripts to unload and load the configuration

For desktoppr, we already have the binary (downloadable as a zip from the project release page) and a sample configuration property list file.

The sample launchd configuration looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.scriptingosx.desktopprmanage</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/desktoppr</string>
        <string>manage</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StartInterval</key>
    <integer>10800</integer>
</dict>
</plist>

The label is required and is used to uniquely identify the LaunchAgent or Daemon with launchd and the launchctl command. Reverse domain notification is common. The filename of the configuration property list should match the label.

The ProgramArguments key provides an array with the command and arguments that will be launched. The first item in the array should provide the full path to the binary or script. Then you provide arguments, one string item per argument. Since the individual arguments are separated by the string tags, you do not need to escape spaces or other special characters in the arguments.

It is worth noting that launchd will launch the binary or script directly, so syntax and substitutions that are done in shells will not work. This includes variable and command substitution like $HOME, $USER, or $(date) as well as pipes and output redirection. You get a single command with a list of static arguments. (You can use the StandardOutPath and StandardErrorPath keys in the launchd config plist to redirect output.)

For the desktoppr LaunchAgent, that suits us just fine, as we are only passing the manage argument which tells desktoppr to get the details from a configuration profile (sample here).

The next key, RunAtLoad tells launchd, to run the binary with the arguments immediately when the configuration file is loaded. Since configuration files in /Library/LaunchAgents and ~/Library/LaunchAgents are automatically loaded when a user logs in, this generally means desktoppr manage will be run when a user logs in, which suits us well.

The last key StartInterval tells launchd to re-launch the process after a certain time (given in seconds, the 10800 seconds in our sample file translate to three hours). Should the system be sleeping at the time, it will not run the LaunchAgent at that time, or when the system wakes up, but wait until the next interval period comes around.

There are other keys that control when the process gets launched, such as StartCalendarInterval or WatchPath. You can read details in the man page for launchd.plist or on the excellent launchd.info page. There are also apps, like Peter Borg’s Lingon that provide a user interface for creating these plist files.

Move or copy the plist file into the correct folder. The plist files in /Library/LaunchDaemons and /Library/LaunchAgents must be owned by root. They must not be writable by group or other. The file mode 644 (rw-r--r--) is recommended. Similarly, the binary or script needs to be owned by root and cannot be writable by group or other. The file mode 755 (rwxr-xr-x) is recommended. Wrong file privileges will result in a Load/Bootstrap failed: 5: Input/output error.

Make sure that the binary or script does not have a quarantine flag attached. You can check with ls -al@ or xattr -p com.apple.quarantine /path/to/file and remove a quarantine flag with xattr -d com.apple.quarantine /path/to/file. When the binary or script has the quarantine flag, the configuration file will load fine but the actual execution will fail quietly.

Loading the LaunchAgent or LaunchDaemon

The easiest way to load a LaunchDaemon is to restart the Mac. The easiest way to load a LaunchAgent is to log out and login. This is usually not practical.

The launchctl command manages LaunchDaemons and LaunchAgents and we can use this to load LaunchAgents and LaunchDaemons on demand.

To load our desktoppr agent, use

> launchctl load /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

People are already readying their pitchforks here. “Hold on,” they say. “Apple has marked load and unload as ‘legacy.’ You shouldn’t use them!”

While it is true that load and unload (and a bunch of other commands) are labeled as ‘legacy’ in the launchctl man page, this does not mean they are deprecated and should be avoided. The difference between the legacy and the ‘new’ commands is that the legacy commands pick up whether a process should be run in user or root context (the ‘target domain’) from the context that launchctl runs in, whereas the new bootstrap and bootout commands need the target domain to be stated explicitly.

This makes the modern commands more precise, but often more wordy to use. You will see that the legacy commands are simpler to use in the interactive terminal, while the ‘modern’ commands are more useful in scripts. The equivalent ‘modern’ command is:

> launchctl bootstrap gui/501 /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

where 501 is the user ID for your user account. This is 501 by default for the first user on on a macOS system, but you should verify what your account’s user ID actually is with id -u.

See what I mean with “more wordy to use”? That doesn’t mean they are bad or should be avoided, but that we can be picky about when to use which format. You could argue that using the modern commands throughout would be more consistent, but you still find the ‘legacy’ command is lots of documentation, so I feel a Mac admin needs to be aware of both.

Note: the ‘modern’ commands were introduced in OS X Yosemite 10.10 in 2014.

To load a LaunchDaemon, you need to run launchctl with root privileges. In the interactive shell, that means with sudo:

> sudo launchctl load /Library/LaunchDaemons/com.example.daemon.plist

or

> sudo launchctl bootstrap system/ /Library/LaunchDaemons/com.example.daemon.plist

To stop a LaunchAgent from being launched going forward, unload the configuration:

> launchctl unload /Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

or

> launchctl bootout gui/501/com.scriptingosx.desktopprmanage

The bootout command uses the label instead of the file path.

Putting it all together in an installer

Note: I will be showing how to build and installer package for our LaunchAgent using command line tools. These instructions should have all the information you need, even if you prefer using apps such as Whitebox Packages or Composer.

If you are not comfortable with using Terminal on macOS yet, please consider my book “macOS Terminal and Shell.” If you want to learn more about building installation packages, consider my book “Packaging for Apple Administrators.”

Download the zip file for desktoppr from the releases page. Download the sample launchd property list file and modify the launch criteria (StartInterval or StartCalendarInterval) to your requirements.

Open Terminal, change directory to a location where you want to create the project directory and create a project directory:

> mkdir DesktopprManagePkg
> cd DesktopprManagePkg

Then create a payload and a scripts directory in this project folder:

> mkdir payload
> mkdir scripts

Unzip the desktoppr binary from the downloaded zip archive into the right folder hierarchy in the payload folder:

> mkdir -p payload/usr/local/bin/
> ditto -x -k ~/Downloads/desktoppr-0.5-218.zip payload/usr/local/bin/

Remove the quarantine flag from the expanded desktoppr binary and test it by getting the version and the current wallpaper/desktop picture:

> xattr -d com.apple.quarantine payload/usr/local/bin/desktoppr
> payload/usr/local/bin/desktoppr version
0.5
> payload/usr/local/bin/desktoppr
/System/Library/CoreServices/DefaultDesktop.heic

Create the /Library/LaunchAgents directory in the payload and copy or move the LaunchAgent configuration plist there:

> mkdir -p payload/Library/LaunchAgents/
> cp ~/Downloads/com.scriptingosx.desktopprmanage.plist payload/Library/LaunchAgents/

When the installation package runs, the files in the payload will be moved to the respective locations on the target drive. The pkgbuild tool will set the owner of the files to root when building the package, but we should verify that the file mode is correct:

> stat -f %Sp payload/usr/local/bin/desktoppr
-rwxr-xr-x
> stat -f %Sp payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist
-rw-r--r--

If the privileges don’t match the desired mode, set them with chmod

> chmod 755 payload/usr/local/bin/desktoppr
> chmod 644 payload/Library/LaunchAgents/com.scriptingosx.desktopprmanage.plist

and run the stat commands from above again to verify.

If you built the installer package now, it will install the files in the right locations and the LaunchAgent will load and run desktoppr manage on next login. This will set the desktop/wallpaper according to the information in the configuration profile. You should install and update the configuration profile using your management system.

Loading the LaunchAgent or LaunchDaemon

However, as I said earlier, requiring or waiting for a logout or reboot is not ideal, we would like to have the LaunchAgent load immediately when the package is installed. We can achieve this by adding a postinstall script to the installation package. This script will be executed after the payload files have been installed.

With your favored text editor, create a file named postinstall (no file extension) with this content in the scripts sub directory.

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"

# do not load when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
    echo "not installing on startup volume, exiting"
    exit 0
fi

# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/

currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')

# don't load at loginwindow 
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
    echo "no user logged in, exiting"
    exit 0
fi

uid=$(id -u "$currentUser")

echo "loading $label"
launchctl bootstrap "gui/$uid" "$launchdPlist"

Set the proper file privileges on the postinstall file with

> chmod 755 scripts/postinstall

First the script sets the PATH. Then some variables for the label and bath to the label. You can change these to adapt the script to other LaunchAgents or LaunchDaemons.

Then the scripts checks the $3 argument. The installer system passes the target volume to the postinstall script in this argument. While it is a not common in deployment workflows any more, you can still install a pkg on a target volume that is not the current boot volume “/“. We remain safe with our script and check for that possibility, and exit without loading the agent.

LaunchAgents have to be loaded or bootstrapped into the current user’s context. The script gets the current user and checks whether the system might be sitting at the login window. We exit without loading if the system is at the login window or if the current user is _mbsetupuser which means the package is being installed while the system is sitting at Setup Assistant during first setup. Now that the LaunchAgent plist is in place, it will be loaded the first time the user logs in.

When there is a user logged in, we bootstrap the LaunchAgent right away. This is where the modern launchctl syntax shows its advantages. With the legacy commands, this would be:

launchctl asuser "$uid" launchctl load "$launchdPlist"

When you load a LaunchDaemon in a postinstall, the script is much simpler, as you don’t need to check for a current user. You can directly load the daemon into the system target domain:

launchctl bootstrap system/ "$launchdPlist"

Unloading before installation

When you are updating existing software with a package, you have to consider the situation where a LaunchAgent or LaunchDaemon is already running. In this case, we should unload before the installation. We can add a preinstall script (also no file extension) to the scripts folder that will be run before the payload is installed. This closely parallels the postinstall script.

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

label="com.scriptingosx.desktopprmanage"
launchdPlist="/Library/LaunchAgents/${label}.plist"

# do not unload when target volume ($3) is not current startup volume
if [ "$3" != "/" ]; then
    echo "not installing on startup volume, exiting"
    exit 0
fi

if ! launchctl list | grep -q "$label"; then
    echo "$label not loaded, exiting"
    exit 0
fi

# for LaunchAgent, check if user is logged in
# see https://scriptingosx.com/2019/09/get-current-user-in-shell-scripts-on-macos/

currentUser=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')

# don't unload at loginwindow or Setup Assistant
if [ "$currentUser" = "loginwindow" ] || [ "$currentUser" = "_mbsetupuser" ]; then
    echo "no user logged in, exiting"
    exit 0
fi

uid=$(id -u "$currentUser")

echo "loading $label"
launchctl bootout "gui/$uid" "$launchdPlist"

Again, set the proper file privileges on the preinstall file with

> chmod 755 scripts/preinstall

There is one extra step compared to the postinstall script. The preinstall uses launchctl list to check if the launchd configuration is loaded, before attempting to unload it.

Building the Package Installer

Now we get to put everything together. You can use pkgbuild to build an installer package:

> pkgbuild --root payload/ --scripts scripts/ --version 1 --identifier com.scriptingosx.desktopprmanage --install-location / DesktopprManage-1.pkg   
pkgbuild: Inferring bundle components from contents of payload/
pkgbuild: Adding top-level preinstall script
pkgbuild: Adding top-level postinstall script
pkgbuild: Wrote package to DesktopprManage-1.pkg

This works well, but it is difficult to memorize all the proper arguments for the pkgbuild command. If you forget or misconfigure one of them it will either fail outright or lead to a pkg installer file with unwanted behavior. I prefer to put the command in a script. Create a script file buildDesktopprManages.sh at the top of your project directory with these contents:

#!/bin/sh

export PATH=/usr/bin:/bin:/usr/sbin:/sbin

pkgname="DesktopprManage"
version="1.1"
identifier="com.scriptingosx.${pkgname}"
install_location="/"
minOSVersion="10.13"

# determine enclosing folder
projectfolder=$(dirname "$0")

# build the component package
pkgbuild --root "${projectfolder}/payload" \
         --identifier "${identifier}" \
         --version "${version}" \
         --ownership recommended \
         --install-location "${install_location}" \
         --scripts "${projectfolder}/scripts" \
         --min-os-version "${minOSVersion}" \
         --compression latest \
         "${pkgname}-${version}.pkg"

This way, you do not have to memorize the pkgbuild command and its arguments and it is easier to modify parameters such as the version. Remember to change the version as you build newer versions of this package going forward!

Some management systems deploy a pkg file using certain MDM commands. Then you need a signed distribution package instead of a simple component pkg file. (You can learn more about the different types of packages in this blog post or this MacDevOps YVR presentation. Building a sign distribution pkg is somewhat more involved. I have a template script for that in the repo.

Conclusion

As I said, building a LaunchAgent for desktoppr manage is not trivial. However, building LaunchAgents and LaunchDaemons and proper installer pkgs to deploy them is an important skill for Mac admins.

There are some other tools available, such as outset, or your management system might have options to run scripts at certain triggers. But in general, I recommend to understand the underlying processes and technologies before using tools, even when the tools are great and a good fit for the task at hand.

You can use this process of building a LaunchAgent and installer pkg for desktoppr as a template for your own scripts and projects. You can find all the files in the desktoppr repo example directory.

zsh scripts and Root Escalations

There was an update for the Support.app this week which fixed a CVE. There is an argument to be had about whether this CVE deserves its high rating, but it is worth discussing the underlying issue and presenting some solutions. (Erik Gomez has some great comments on Mac Admins Slack.)

zsh is far more configurable than most other shells, most certainly more configurable than the aging bash 3.2 that comes with macOS. One aspect of being more configurable is that it has multiple different configuration files that are loaded at different times when the shell starts. The most common configuration file you will have encountered is ~/.zshrc which is loaded for all interactive shells.

Another, less commonly used, configuration file, ~/.zshenv (and its global sibling /etc/zshenv) is loaded every time a zsh process launches, including script launches. That means, when you (or something else in the system) launches a script with a #!/bin/zsh shebang, the zsh process will read the files /etc/zshenv and ~/.zshenv when they exist, and execute their code.

Update 2024-03-22: some links for further reading:

The Setup

We can see this in action.

First, create a simple zsh “hello.sh” type script with this content:

#!/bin/zsh
echo "Hello, $(whoami)"

Make the script file executable (chmod +x hello.sh) and run it. You should see output like

% ./hello.sh
Hello, armin

where armin is replaced with your current username.

Next, create a new file ~/.zshenv (~/ means at the root of your home directory) with your favored text editor and add the following line:

echo "zshenv: as $(whoami) called from $0"

If you already have a ~/.zshenv you will want to rename it for now so we don’t modify that. (mv ~/.zshenv ~/.zshenv_old)

Note that the configuration file neither has a shebang, nor does it need to be executable.

When you open a new Terminal window, you should see the line

zshenv: as armin called from -zsh

among the other output at the top of the Terminal window, since ~/.zshenv is read and evaluated every time a zsh process starts. The shebang at the beginning of the script ensures a new zsh environment is created for it, even when your interactive shell is not zsh.

When you run hello.sh you will see that ~/.zshenv is executed as well:

% ./hello.sh
zshenv: as armin called from /bin/zsh
Hello, armin

So far, so good. This is how zshenv is supposed to work. It’s purpose is to contain environment variables and other settings that apply to all processes on the system. On macOS this is undermined by the fact that most apps and process are not started from a shell, so they don’t see environment variables set anywhere in the zsh (or another shell’s) configuration files, so .zshenv is rarely used. .zshrc is far more useful.

But now we come to escalation aspect: run hello.sh with root privileges using sudo:

% sudo ./hello.sh
zshenv: as root called from /bin/zsh
Hello, root

We see that both our script and ~/.zshenv are run with root privileges. Again, that is how sudo and .zshenv are supposed to work.

Since the zshenv configuration files are evaluated every time a zsh is launched, it is far more likely that they will eventually run with elevated privileges, than the other configuration files. Other shells, like bash and sh have no equivalent configuration file that gets run on every launch.

The Escalation

The potential danger is the following: a process only needs user privileges to create or modify ~/.zshenv. A malicious attacker can inject code into your .zshenv, wait patiently for a script with a zsh shebang to be run with root privileges, and then execute some malicious code with root privileges on your system.

This can also occur with preinstall or postinstall scripts with a zsh shebang in installation package files (pkg files) when the installation is initiated by the user. A malicious attacker could scan your system for apps where they know the installer package contains zsh scripts, which makes the chance that eventually a zsh script is run with root permissions quite certain. Then all they have to do is be patient and wait…

As root escalations go, this is not a very efficient one. The attacker is at the mercy of the user to wait when they execute a zsh script with escalated root privileges. On some Macs, that may very well be never.

This also only works when the user can gain administrative privileges. Standard users cannot use sudo to gain root privileges, or run installation packages.

There are more effective means of escalating from user to root privileges on macOS. Most easily by asking the user directly for the password with a dialog that appears benign. However, if you are a frequent user or author of zsh scripts, it is important to be aware.

From what I can tell so far, this does not affect scripts launched from non-user contexts, such as scripts from Munki, Jamf Pro or other management solutions, though there may be odd, unexpected edge cases.

As an organization, you can monitor changes to all shell configuration files with a security tool (such as Jamf Protect). There are more shenanigans an attacker could achieve by modifying these files. There are, however, many legit reasons to change these files, so most changes will not indicate an attack or intrusion. Nevertheless, the information could be an important puzzle piece in combination with other behaviors, when putting together the progress or pattern of an intrusion.

Update 2024-03-22: Matteo Bolognini has added a custom analytic for Jamf Protect to detect changes to the zshenv file.

Configuration file protection

This weakness is fairly straightforward to protect against. First, since the attack requires modification of ~/.zshenv, you can protect that file.

If you did not have a ~/.zshenv, create an empty file in its place or remove the line of code from our sample .zshenv before. Then apply the following flag:

% sudo chflags schg ~/.zshenv

This command sets the system immutable flag (schg, ‘system change’), which makes it require root privileges to modify, rename or delete the file.

If you want to later modify this file, you can unlock it with

% sudo chflags noschg ~/.zshenv

Just remember to protect it again when you are done.

Script protection

Since most Mac users will never open terminal and never touch any shell configuration file, relying on protecting the configuration file is not sufficient. However, it is quite easy to protect the zsh scripts you create from this kind of abuse.

One solution is to use a $!/bin/sh shebang instead of #!/bin/zsh. Posix sh does not have a configuration file which gets loaded on every launch, so this problem does not exist for sh.

If you are using zsh features that are not part of the POSIX sh standard you will need to change the script. In my opinion, installation scripts that are too complex to use POSIX sh, are attempting to do too much and should be simplified, anyway. So, this can also work as an indicator for “good” installation scripts.

In addition, installer packages might be used in situations where zsh is not available. Installation tools that install package files when the Mac is booted to Recovery (where there is no zsh) are used far less than they used to, but it is still possible and making your installer pkgs resilient to all use cases is generally a good choice.

I believe for installation package scripts, using an sh shebang is a good recommendation.

Some scripts popular with Mac Admins, like Installomator, do not usually run from installer packages, but are regularly run with root privileges. For these cases, (or for installation scripts, where cannot or do not want to use sh) you can protect from the above escalation, by adding the --no-rcs option to the shebang:

#!/bin/zsh --no-rcs
echo "Hello, $(whoami)"

zsh’s --no-rcs option suppresses the launch of user level configuration files. The rc stands for ‘Run Command’ files and is the same rc that appears in zshrc or bashrc or several other unix configuration files.

That allows you to keep using zsh and zsh features, but still have safe scripts. This is the solution that Support.app and the latest version of Nudge have implemented. I have also created a PR for Installomator, which should be merged in the next release.

Conclusion

Modifying ~/.zshenv is not a very effective means of gaining root privileges, but it is something developers and Mac admins that create zsh script that may be run with root privileges should be aware of. Switch to an sh shebang or add --no-rcs to the zsh shebang of scripts that might be run with root privileges to protect.

Build a notarized package with a Swift Package Manager executable

One of the most popular articles on this blog is “Notarize a Command Line Tool with notarytool.” In that post I introduced a workflow for Xcode to build and notarize a custom installer package for a command line tool. This workflow also works with apps and other projects that require a customized installer package building workflow. I use it in many of my own projects.

But Xcode is not the only way to build Swift binaries. Especially for command line tools, you can also use Swift Package Manager. This provides a mostly command line based interface to building and organizing your project, which you might prefer if you want to use an IDE that is not Xcode, or have Swift projects that need to run cross-platform.

I also have an older article on building a command line tool with Swift Package Manager. But then, I did not create an installer package or notarize the resulting binary.

Placing the binary in an installer package file is the best way to distribute a binary as you can control where in the file system the binary is installed. Notarizing the pkg file is necessary when you are distributing a command line tool, since it enables installations without scary dialogs or handling quarantine flags.

Also, some of the behavior of Swift Package Manager (SPM) and Xcode have changed since the previous posts. So, this article will introduce an updated workflow using Swift Package Manager tools and how to sign, package and notarize a command line tool for distribution.

Note on nomenclature: Swift Package Manager projects are called ‘packages.’ On macOS, installer files (with the pkg file extension) are also called ‘packages.’ We will be using SPM to build a notarized installation package (a pkg file) from a Swift package project. This is confusing. There is not much I can do about that other than using ‘installer package’ and ‘Swift package project’ to help distinguish.

Prerequisites

I wrote this article using Xcode 14.3.1 and Swift 5.8.1. It should also work with somewhat older or newer versions of Xcode and Swift, but I have not tested any explicitly.

Since I said earlier that using Swift Package Manager allows us to not use Xcode and maybe even build a cross-platform project, you may be wondering why we need Xcode. While we don’t need Xcode for our project, it is one way of installing all the tools we need, most importantly the swift and notarytool binaries. You get those from Developer Command Line tools, as well. We will also see that we can combine Xcode with the command line Swift Package Manager workflow, which I find a very useful setup.

To submit a binary to Apple’s notarization process you will need a Personal or Enterprise Apple Developer account, and access to the Developer ID Application and Developer ID Installer certificates from that account. A free Apple Developer account does not provide those certificates, but they are necessary for notarization

You can follow the instructions in the Xcode article on how to get the certificates and how to configure notarytool with an application specific password. If you had already done this previously you should be able to re-use all of that here. When you reach the ‘Preparing the Xcode Project’ section in that article, you can stop and continue here. Apple also has some documentation on how to configure notarytool.

The sample code we will be using will only work on macOS as it uses CoreFoundation functions. Installer packages and notarization are features of macOS, too, so this is not really a problem here. You can use this workflow to build macOS specific signed binaries and notarized installation pkg files from a cross-platform Swift package project. This will work as long as you keep in mind that the tools to sign, package and notarize only exist and/or work on macOS.

The sample code

We will build the same simple sample tool as in the last article. The prf command (short for ‘pref’ or ‘preference’) reads a default setting’s effective value using the CFPreferencesCopyAppValue function.

The macOS defaults command will read preferences, but only from the user level, or from a specified file path. This ignores one of the main features of macOS’ preferences system as it will not show if a value is being managed by a different preference level, such as the global domain, a file in /Library/Preferences, or (most importantly for MacAdmins) a configuration profile.

You can learn all about preferences and profiles in my book “Property Lists, Preferences and Profiles for Apple Administrators.”

We will build a really simple command line tool, named prf which shows the effective value of a setting, no matter where the value comes from. You could make this tool far more elaborate, but we will keep it simple, since the code is not the actual topic for this article.

We will also be using the Swift Argument Parser package to parse command line arguments and provide a help message. We could build this simple tool without using Argument Parser, but using an external package module is one of the strengths of using Swift Package Manager.

Create the Swift Package project

With all the preparations done, it is time to create our Swift package. We will do all the work in the shell, so open Terminal or your other favorite terminal app and navigate to the directory where you want to create the project.

> cd ~/Projects

Then create a new directory with the name swift-prf. This will contain all the files from the Swift package project. Change directory into that new directory. All following commands will assume this project directory is the current working directory.

> mkdir swift-prf
> cd swift-prf

Then run the swift tool to setup the template structure for our command line tool or ‘executable.’

> swift package init --type executable 
Creating executable package: swift-prf
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/main.swift

You can inspect the hierarchy of files that the init tool created in the Finder (open .) or in your preferred editor or IDE.

.gitignore
Package.swift
Sources
    main.swift

`

You can open this package project in Xcode. In older versions of Xcode you had to run a special swift package command to generate the Xcode project, but now, Xcode can open Swift package projects directly. Use xed (the ‘Xcode text editor invocation tool’) to open the current directory in Xcode.

> xed .

There is a pre-filled .gitignore (which will be hidden in Finder and probably your IDE), a Package.swift, and a Sources directory with a single main.swift inside. If you want to use git (or another version control) system, now is the time to initialize with git init.

Build the project with swift build and/or run it with swift run. Not surprisingly, the template prints Hello, world!.

> swift build
Building for debugging...
[3/3] Linking swift-prf
Build complete! (0.92s)
> swift run  
Building for debugging...
Build complete! (0.11s)
Hello, world!

After building, there will also be a .build directory (also hidden in Finder, unless you toggle the visibility of invisible files using shift-command-.) which contains all the interim files. In the debug folder, you can find the swift-prf executable. You can run it directly:

> .build/debug/swift-prf
Hello, world!

You can clean all the generated pieces from the .build directory with swift package clean. This will leave some empty folders behind but remove all the interim and final products. This means the next build is going to take much longer, but this can be helpful after reconfiguring the Package.swift file or when the compiler gets confused.

Sidenote: when you use Xcode to edit your Swift package project, and choose Build or Run from the Xcode interface, then it will build and run in a different location (~/Library/Developer/Xcode/DerivedData/swift-prf-<random-letters>/Build). You need to be aware of this when you alternate between Xcode and the command line.

Configuring the Package

The Package.swift file contains the configuration for a Swift package project. You can see that the executable package template has a single target named swift-prf that builds from the files in Sources.

To change the name of the executable file, change the value of the name: of the .executableTarget to just prf. There is another name: earlier in the file, that sets the name of the entire project, you can leave that being swift-prf. They do not have to match.

Then build the project in the command line and run it directly:

> swift build
Building for debugging...
[3/3] Linking prf
Build complete! (0.51s)
> .build/debug/prf          
Hello, world!

We want to add the Swift Argument Parser package to our project as a dependency, so we can use its functionality in our code. For that, we will have to add a ‘dependency’ to the project and then to the target, as well. Modify the Package.swift file to match this:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
  name: "swift-prf",
  products: [
    .executable(name: "prf", targets: ["prf"]),
  ],
  dependencies: [
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
  ],
  targets: [
    .executableTarget(
      name: "prf",
      dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")],
      path: "Sources")
  ]
)

This means that our project uses the package available at the given URL, and our target is going to use the specific product (or module or framework) named ArgumentParser from that package. Some packages have several products combined out of several targets.

You can find more information on the format of the Package.swift file in this overview, and the full documentation.

The next time you build after this change, it will download the repository, build and link to toward your executable. That might take a while. The next build should be much faster again. Also, a Package.resolved file will appear in the project. This file caches the current versions of the included packages protecting you from unexpected changes when a package repo dependency updates. You can force Swift Package Manager to update the file with swift package update.

Sprinkle some code

Now that we have the Swift package project prepared, we can add the code to actually do something.

First, let’s keep the ‘Hello, world!’ for a moment, but put it in the right struct to use ArgumentParser. Change main.swift to:

import Foundation
import ArgumentParser
@main
struct PRF: ParsableCommand {
  func run() {
    print("Hello, world!")
  }
}

This should build and run fine from the command line with swift build and swift run. However, when you open this now in Xcode, you will see an error: 'main' attribute cannot be used in a module that contains top-level code

This comes from a long-running issue in Swift. In older versions of Swift it appears on the command line, as well. The work-around is easy though. It only seems to appear when the @main designator is the main.swift file. We can rename our main file to PRF.swift.

You may want to close the Xcode project window before you do this because this can confuse Xcode. If you manage to get Xcode into a confused state where the project in Xcode does not match what is on disk any more, quit Xcode and delete the .swiftpm/xcode directory, which is where Xcode keeps its generated files.

> mv Sources/main.swift Sources/PRF.swift

Now the project should build and run the same with the Swift Package Manager tools and in Xcode.

Now we can add the ‘full’ code for our tool. Keep in mind that the goal of this tutorial is not to learn how to write complex swift code for command line tools, but to learn the infrastructure requires to create and distribute them, so this code is intentionally simple and basic.

import Foundation
import ArgumentParser
@main
struct PRF: ParsableCommand {
  static var configuration = CommandConfiguration(
    commandName: "prf",
    abstract: "read effective preference value",
    version: "1.0"
  )
  @Argument(help: "the preference domain, e.g. 'com.apple.dock'")
  var domain: String
  @Argument(help: "the preference key, e.g. 'orientation'")
  var key: String
  func run() {
    let plist = CFPreferencesCopyAppValue(key as CFString, domain as CFString)
    print(plist?.description ?? "<no value>")
  }
}

When you compare that to the code from the last article, there are a few differences. We are using the @main attribute to designate the main entry point for the code (this was added in Swift 5.3) and I have added some help text to the tool and argument declarations.

When you use Swift Argument Parser, you should study the documentation on adding help to [commands](I have added some help text to the tool and argument declarations. ) and flags, arguments and options. (To be honest, you should read the entire documentation, a lot has changed since the last article.)

When you now run the tool:

> swift run  
Building for debugging...
[3/3] Linking prf
Build complete! (0.54s)
Error: Missing expected argument '<domain>'
OVERVIEW: read effective preference value
USAGE: prf <domain> <key>
ARGUMENTS:
  <domain>                the preference domain, e.g. 'com.apple.dock'
  <key>                   the preference key, e.g. 'orientation'
OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

We get the help text generated by Swift Argument Parser with the extra information we provided in the code.

If you want to provide the arguments to the swift run you have to add the executable name, as well:

> swift run prf com.apple.dock orientation       
Building for debugging...
Build complete! (0.11s)
left

Or you can run the executable directly from the .build/debug directory. (This will not automatically re-build the command like swift run does.

> .build/debug/prf com.apple.dock orientation
left

Since we provided a version in the CommandConfiguration, ArgumentParser automatically generates a --version option:

> .build/debug/prf --version       
1.0

Now that we have a simple but working tool, we can tackle the main part: we will package and notarize the tool for distribution.

Preparing the binary

When you run swift build or swift run it will compile the tool in a form that is optimized for debugging. This is not the form you want to distribute the binary in. Also, we want to compile the release binary as a ‘universal’ binary, which means it will contain the code for both Intel and Apple silicon, no matter which CPU architecture we are building this on.

The command to build a universal release binary is

> swift build --configuration release --arch arm64 --arch x86_64

When that command is finished, you will find the universal binary file in .build/apple/Products/Release/prf. we can check that it contains the Intel (x86_64) and Apple silicon (arm64) with the lipo tool:

> lipo -info .build/apple/Products/Release/prf
Architectures in the fat file: .build/apple/Products/Release/prf are: x86_64 arm64 

For comparison, the debug version of the binary only contains the platform you are currently on:

> lipo -info .build/debug/prf
Non-fat file: .build/debug/prf is architecture: arm64

Apple’s notarization process requires submitted binaries to fulfill a few restrictions. They need a timestamped signature with a valid Developer ID and have the ‘hardened runtime’ enabled.

Xcode will always sign code it generates, but the swift command line tool does not. We will have to sign it ourselves using the codesign tool. You will need the full name of your “Developer ID Application” certificate for this. (Don’t confuse it with the “Developer ID Installer” certificate, which we will need later.)

You can list the available certs with

> security find-identity -p basic -v

and copy the entire name (including the quotes) of your certificate. Then run codesign:

> codesign --sign "Developer ID Application: Your Name (ABCDEFGHJK)" --options runtime  --timestamp .build/apple/Products/Release/prf

You can verify the code signature with

> codesign --display --verbose .build/apple/Products/Release/prf

Build the installation package

Now that we have prepared the binary for distribution, we can wrap it in an package installer file.

To cover all deployment scenarios, we will create a signed ‘product archive.’ You can watch my MacDevOps presentation “The Encyclopedia of Packages” for all the gory details.

First, create a directory that will contain all the files we want put in the pkg. Then we copy the binary there.

> mkdir .build/pkgroot
> cp .build/apple/Products/Release/prf .build/pkgroot/

Then build a component pkg from the pkgroot:

> pkgbuild --root .build/pkgroot --identifier com.scriptingosx.prf --version 1.0 --install-location /usr/local/bin/ prf.pkg

The --identifier uses the common reverse domain notation. This is what the installer system on macOS uses to determine whether an installation is an upgrade, so you really need to pay attention to keep using the same identifier across different versions of the tool. The --version value should change on every update.

The --install-location determines where the contents of the payload (i.e. the contents of the pkgroot directory) get installed to. /usr/local/bin/ is a useful default for macOS, but you can choose other locations here.

Next, we need to wrap the component pkg inside a distribution package.

> productbuild --package prf.pkg --identifier com.scriptingosx.prf --version 1.0 --sign "Developer ID Installer: Your Name (ABCDEFGHJK)" prf-1.0.pkg

It is important that you use the “Developer ID Installer” certificate here. The --identifier and --version are optional with productbuild but this data required for some (admittedly rare) deployment scenarios, and we want to cover them all.

You can inspect the installer pkg file with a package inspection tool such as the amazing Suspicious Package. The package file should as a signed “Product Archive.”

We don’t need the component pkg anymore, and it’s presence might be confusing, so let’s remove it:

> rm prf.pkg

Note: If you want to learn more about building installation packages, check out my book “Packaging for Apple Administrators”

Notarization

We are nearly there, just two more steps.

It is important to notarize pkgs that will be installed by a user, because otherwise they will get a scary warning that Apple can’t verify the pkg for malicious software.

notarytool submits the installer package to Apple’s Notarization process and returns the results. Use the keychain profile name you set up, following the instructions in the previous article or the instructions from the Apple Developer page.

> xcrun notarytool submit prf-1.0.pkg --keychain-profile notary-example.com --wait

This will print a lot of logging, most of which is self-explanatory. The process might stall at the “waiting” step for a while, depending on how busy Apple’s servers are. You should eventually get status: Accepted.

If you got a different status, or if you are curious, you can get more detail about the process, including rejection reasons, with notarytool log. You will need the ‘Submission ID’ from the submission output:

xcrun notarytool log <submission-uuid> --keychain-profile notary-example.com

As the final step, you should ‘staple’ the notarization ticket to the pkg. This means that the (signed) notarization information is attached to the pkg-file, saving a round trip to Apple’s servers to verify the notarization status when a system evaluates the downloaded installer package file.

xcrun stapler staple prf-1.0.pkg
Processing: /Users/armin/Desktop/swift-prf/prf-1.0.pkg
Processing: /Users/armin/Desktop/swift-prf/prf-1.0.pkg
The staple and validate action worked!

And with that, we have a signed and notarized installation pkg file! You can verify this with spctl:

> spctl --assess --verbose -t install prf-1.0.pkg 
prf-1.0.pkg: accepted
source=Notarized Developer ID

Automation

While it is instructive to do this process manually, it is also quite complex and error-prone. If you have been following this blog for any time, you will know that I don’t stop at detailed step-by-step instructions with explanations.

You can find a script to automate all of these steps here. The enclosing repository includes the entire project (all three files) for your reference.

There is a section at the beginning with variables to modify with the information specific to your environment and project, such as your developer ID information and the name of the credential profile for notarytool. Then there are a few variables, such as the product name, and the installation package identifier.

Run the pkgAndNotarize.sh script from the root of the Swift package project directory.

./pkgAndNotarize.sh

The script creates the installer pkg file in the .build directory. The last line of output is the path to the final, signed, notarized and stapled pkg file.

The script mostly follows the process described above, with a few extras. For example, the script determines the version dynamically by running the tool with the --version option. It also uses the modern compression options I described in this post.

If any of the steps in the script fail, you can determine what exactly failed from the output, error message and error code.

(I belief that this could probably be a makefile, but I have no experience with that (yet). I guess I will need to ‘make’ time for this…)

Conclusion

Apple provides developers and MacAdmins with amazing platforms and tools to build all kinds of powerful apps, tools and automations. But then they don’t really document any of the processes or best practices at all. The amount of searching, guesswork, and frustrating trial and error necessary to piece all of this together for a workflow like this one is quite the shocking condemnation of Apple’s documentation.

There are glimmers of hope. The documentation for the notarization process and notarytool are exemplary.

But they only cover one piece of this puzzle. A developer building a tool has to still figure out how to

  • sign all the binaries properly
  • assemble the binaries and resources necessary into an installation package payload
  • how (and when, and when not) to use pre- and postinstall scripts
  • which kind of package installer to build and which tools to use
  • securely manage the Developer ID certificates (this is especially challenging for developer teams)
  • automate this workflow with Xcode or Swift Package Manager or a CI/CD system

MacAdmins often complain about poorly built installer pkgs, and often for good reasons. But to be fair, there are no best practices and little to no documentation for this from Apple. How are developers supposed to know all of this? Most MacAdmins can define what an installer package should do and not do, but wouldn’t be able to explain to a developer how to build such an installer package, let alone integrate that into their build automations. And most developers don’t even know a MacAdmin to ask about this.

Apple requires that developers create signed and notarized archives for software distribution. And I agree wholeheartedly with their motivations and goals here. But when impose requirements for distribution, you have to make the process of creating the installers the correct way easy, or at least well documented, whether you use Xcode or a different tool set, whether you want to distribute a simple self-contained app, a single command line tool, or a complex suite of tools and resources.

Apple has their work cut out to improve this. Official best practices and sample workflows for installer creation and distribution that consider and respect the requirements of MacAdmins for deployment, have been disgracefully lacking for a long time. The more requirements and security Apple piles on to application and tool distribution, the more desperately they need to provide documentation, best practices and guidance.

Until that happens, you have my paltry scripts.

Update: quickpkg 1.0beta

QuickPkg is one of those tools that I built for myself a long time ago and has remained useful (at least to me) without many updates.

Since Apple will be removing the pre-installed Python 2 binary from macOS 12.3, I was forced to pull the code out, dust it off and update it to Python 3. I chose to use the MacAdmins “Managed Python” since it provides a nice bundle of libraries which should be useful for some other tools I have (and still need to migrate). It should (probably) work with other Python 3 distributions, as well, but I did not test this at all.

Thankfully, quickpkg did not require very many code changes. I could even remove all the code from Greg Neagle’s FoundationPlist. Python 2 plistlib could not read binary plist files, so that extra functionality was required. But Python 3 plistlib can read and write binary plists and I could save a lot of code.

The script has worked with all of my tests, but I probably have not explored all the weird edge cases yet. So I am releasing it as a beta. Please file issues and/or pull requests on the GitHub repo if you run into any problems.

Update: pkgcheck

The macOS Monterey 12.3 beta release notes say that the Python 2.7 binary (located in /usr/bin/python) will be removed. Since you follow this blog, this should not come as a surprise. We have been warned about this since Catalina. (Or longer)

That said, the removal of Python 2 in a minor macOS release is surprising. Minor updates should not have breaking changes or removals. Admins and developers may not expect removals and other breaking changes in a minor update and therefore not be paying as much attention to changes. Also, the time a minor update is in beta is usually 6-8 weeks, which leaves us and developers much less time to find and fix problems than a major update beta phase, which is usually 4-5 months.

Nevertheless, we have to work with what Apple deals to us. MacAdmins have been investigating their own tools and scripts since the Monterey release or earlier to avoid the prompts. But when you get vendor pkgs, these might contain anything. While you can inspect pkgs with tools like pkgutil, Pacifist or Suspicious Package, it can get tedious with many packages.

A while back I built a script called pkgcheck to automate this check. Since I (and many others) have started using it again in the recent days, I have added a few more checks to it.

The earlier version would flag files in the installer’s resources that had a /bin/bash, /usr/bin/python, /usr/bin/ruby, or /usr/bin/perl shebang. (the first line with the #!) I have now also added check for a shebang with /usr/bin/env [python|ruby|perl] because when run from an installer pkg, this will also resolve tousing the built-in, deprecated runtimes. Also, using python in the shebang will now be shown as a red error, rather than a yellow warning.

The script will now also grep for use of python in installation scripts and show those scripts. This might generate a few false positives. You will have to use your judgement. For example using python3 in an installation script will also trigger this. But then, it probably should, since python3 is not installed on macOS by default. (What you see in /usr/bin/python3 is a shim that prompts you to install the Command Line Developer Tools, unless they or Xcode are already installed.)

I hope this is useful!

Suspicious Package 4.0 Update

I recently posted about some Suspicious Package Power User Features, which was a follow-up to my MacDevOps YVR presentation “The Encyclopedia of Packages.”

As a follow-up to that follow-up, Suspicious Package was updated to version 4.0 today. (Yesterday? Time zones are strange.) The update to brings compatibility with macOS Monterey and some really nice refinements to these power user features.

First and foremost, Suspicious Package will now show the kind of package, or “package format” in the Package Info tab. This makes me very happy, not just because the FAQ references my presentation. As the FAQ correctly states, most users of packages or even the Suspicious Package application will not care much about the differences between the package formats, but for system administrators, this can determine the difference between a functional deployment or a broken workflow.

The previously ‘secret’ option to show the PackageInfo xml file is now also exposed in the preferences window, next to the option to show the Distribution XML.

It is now also easier to search for the contents of a particular component in a distribution package.

You can download the latest version of Suspicious Package and get the update notes here.

Many thanks to Randy Saldinger of Mothers Ruin Software for providing this amazing tool and further refining it!

Randy was also recently a guest on the MacAdmins Podcast. You check it out if you have not yet listened to that episode.