From f04a96b370016f5cf5fc8e286d6c8ebe5d34760b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 8 Sep 2026 19:16:58 +0800 Subject: [PATCH] Audit the released image, and the app inside it release.sh verified the disk image and stopped there, which is how the app inside it came to be shipped without a ticket of its own: nothing looked. That was fixed by stapling the app, but the fix had no guard, so it could go away as quietly as it arrived. audit-release.sh checks both, along with the Developer ID authority, the hardened runtime, the secure timestamp, the Gatekeeper verdict, the architecture and the version. release.sh calls it in place of its two inline checks, and it takes a path, so a downloaded image can be put through the same audit. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/audit-release.sh | 109 +++++++++++++++++++++++++++++++++++++++ scripts/release.sh | 6 +-- 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100755 scripts/audit-release.sh diff --git a/scripts/audit-release.sh b/scripts/audit-release.sh new file mode 100755 index 0000000..41f0267 --- /dev/null +++ b/scripts/audit-release.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Checks a built disk image the way a first-time download is checked, and +# fails on anything a downloader would hit. +# +# release.sh runs this at the end, but it takes a path, so it also works on a +# downloaded image: +# +# ./scripts/audit-release.sh ~/Downloads/TypeSwitch-1.0.0.dmg 1.0.0 +set -eu + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: $0 [expected-version]" >&2 + exit 2 +fi + +dmg=$1 +expected_version=${2-} +work_dir=$(mktemp -d -t typeswitch-audit) +work_dir=$(CDPATH='' cd -- "$work_dir" && pwd -P) +mount_dir="$work_dir/mount" +mkdir "$mount_dir" +mounted=0 + +cleanup() { + status=$? + trap - EXIT HUP INT TERM + + if [ "$mounted" -eq 1 ] && ! hdiutil detach "$mount_dir" >/dev/null; then + echo "Audit cleanup failed: could not detach $mount_dir." >&2 + status=1 + fi + + if mount | grep -Fq " on $mount_dir "; then + echo "Audit cleanup warning: $mount_dir is still mounted." >&2 + status=1 + else + case "$(basename "$work_dir")" in + typeswitch-audit.*) rm -rf "$work_dir" || status=1 ;; + *) echo "Refusing to remove unexpected audit directory: $work_dir" >&2; status=1 ;; + esac + fi + + exit "$status" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +fail() { + echo "Release audit failed: $1" >&2 + exit 1 +} + +if [ ! -f "$dmg" ]; then + fail "no disk image at $dmg" +fi + +hdiutil verify "$dmg" >/dev/null + +xcrun stapler validate "$dmg" >/dev/null 2>&1 \ + || fail "the disk image carries no stapled notarization ticket." + +spctl --assess --type open --context context:primary-signature "$dmg" >/dev/null 2>&1 \ + || fail "Gatekeeper rejects the disk image." + +hdiutil attach -nobrowse -readonly -mountpoint "$mount_dir" "$dmg" >/dev/null +mounted=1 + +app="$mount_dir/TypeSwitch.app" +executable="$app/Contents/MacOS/TypeSwitch" + +[ -d "$app" ] || fail "the disk image holds no TypeSwitch.app." + +codesign --verify --deep --strict "$app" +signature=$(codesign -d --verbose=4 "$app" 2>&1) + +printf '%s\n' "$signature" | grep -q '^Authority=Developer ID Application: ' \ + || fail "the app is not signed with a Developer ID Application certificate." +printf '%s\n' "$signature" | grep -q '^TeamIdentifier=[A-Z0-9]' \ + || fail "no team identifier is embedded." +printf '%s\n' "$signature" | grep -q '^Timestamp=' \ + || fail "the signature carries no secure timestamp." +printf '%s\n' "$signature" | grep -q 'flags=.*runtime' \ + || fail "the hardened runtime is not enabled." + +# The reason this script exists. Stapling only the image leaves the copy +# someone drags out of it depending on a network round trip to Apple on first +# launch, and nothing else here would notice that coming back. +xcrun stapler validate "$app" >/dev/null 2>&1 \ + || fail "the app inside the image carries no stapled ticket of its own." + +assessment=$(spctl --assess --type exec --verbose=4 "$app" 2>&1 || true) +printf '%s\n' "$assessment" | grep -Fq "source=Notarized Developer ID" \ + || fail "Gatekeeper does not report the app as notarized: +$assessment" + +if [ -n "$expected_version" ]; then + actual=$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist") + [ "$actual" = "$expected_version" ] \ + || fail "expected version $expected_version, found $actual." +fi + +architectures=$(lipo -archs "$executable") +case " $architectures " in + *" arm64 "*) ;; + *) fail "Apple silicon architecture is missing (found: $architectures)." ;; +esac + +echo "Release audit passed: Developer ID signature, hardened runtime, secure timestamp," +echo "tickets stapled to both the image and the app, Gatekeeper accepts, arm64 present." diff --git a/scripts/release.sh b/scripts/release.sh index eabea14..95d4924 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -135,10 +135,10 @@ xcrun stapler staple "$dmg" # --- Verify ---------------------------------------------------------------- # What a first-time download goes through, checked here rather than discovered -# by whoever downloads it. +# by whoever downloads it. The image and the app inside it are both checked, +# which is what stops the stapling above from quietly going away again. -xcrun stapler validate "$dmg" -spctl --assess --type open --context context:primary-signature -v "$dmg" +"$project_dir/scripts/audit-release.sh" "$dmg" "$version" # Written from inside the directory, so the file names the image rather than # this machine's directory layout: `shasum -c` looks for the path it is given,