#!/bin/bash # # hawser-check - inspect a Mac app you already shipped. # # ./hawser-check MyApp.dmg # ./hawser-check /Applications/MyApp.app # ./hawser-check MyApp.dmg --appcast https://example.com/appcast.xml # # It reads. It never writes, installs, uploads or phones home. Two honest # footnotes on that, because a claim like it is worth only its exceptions: # # * A DMG has to be mounted to look inside it. That is a real side effect, # not a write: read-only, -nobrowse, and detached again by a trap on the # way out. A SIGKILL would leave it mounted; `hdiutil detach` clears it. # * The network. This fetches the Sparkle feed your app already points at, # so it can tell you whether that feed still answers (--offline removes # it). Separately, `spctl` is a Gatekeeper evaluation and macOS may # consult Apple during one, which is outside this script's control and # happens whenever anything on your Mac is assessed. # # What it is: the read-only half of the checks in Hawser's ship.sh and # appcast.sh, pointed at a finished artifact instead of one being built. Every # check here answers the same question: is this the thing you think you # shipped? The failures worth paying attention to are the quiet ones, where # nothing on your own machine looks wrong. # # Free, unlicensed, and yours to read before you run it. hawserkit.com # set -u -o pipefail VERSION="1.0" # ---------------------------------------------------------------- output --- # Colour only when a terminal is watching, so piping to a file or a CI log # produces plain text rather than escape codes. if [[ -t 1 ]]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; D=$'\033[2m'; B=$'\033[1m'; X=$'\033[0m' else R=""; G=""; Y=""; D=""; B=""; X="" fi PASSED=0 FAILED=0 SKIPPED=0 section() { printf '\n %s%s%s\n' "$B" "$1" "$X"; } pass() { PASSED=$((PASSED+1)); printf ' %s✓%s %s\n' "$G" "$X" "$1"; } skip() { SKIPPED=$((SKIPPED+1)); printf ' %s-%s %s%s%s\n' "$D" "$X" "$D" "$1" "$X"; } # A failure is two things: what is wrong, and what it means for the people who # already downloaded your app. The second line is the one that matters, because # every failure in here looks fine from where you are sitting. fail() { FAILED=$((FAILED+1)) printf ' %s✗%s %s%s%s\n' "$R" "$X" "$B" "$1" "$X" shift local line for line in "$@"; do printf ' %s%s%s\n' "$D" "$line" "$X"; done } note() { printf ' %s%s%s\n' "$D" "$1" "$X"; } # grep -c prints its count AND exits 1 when that count is zero. Chaining # `|| echo 0` onto it therefore appends a SECOND zero and the variable becomes # the two-line string "0\n0", which every later [[ ]] arithmetic comparison # rejects outright. This is not hypothetical: it silently killed the "no item # in the feed is signed" branch, which is the check this tool is pointed at # most often, and printed a bash syntax error in its place. Count once, here. # grep -c counts matching LINES, not matches, and an appcast is allowed to put # several elements on one line. Count occurrences with -o instead. count() { local n n=$(grep -o "$1" <<<"$2" 2>/dev/null | wc -l | tr -d ' ') printf '%s' "${n:-0}" } die() { printf '\n %sCannot run:%s %s\n\n' "$R" "$X" "$1" >&2; exit 2; } usage() { cat <<'USAGE' hawser-check - inspect a Mac app you already shipped. hawser-check a .dmg, a .app, or a .zip containing one hawser-check --appcast check a feed other than the app's own hawser-check --offline skip the feed check entirely If the app declares a Sparkle feed, that feed is fetched and checked. That is the only request this script makes, and --offline removes it. (Gatekeeper evaluation is macOS's own business: spctl may consult Apple during one.) It reads. It writes nothing, installs nothing and reports nothing anywhere. A DMG is mounted read-only to look inside it and unmounted again on the way out. Exit codes: 0 every check passed 1 at least one check failed 2 could not run (no such file, wrong kind of file, not macOS) USAGE } # ------------------------------------------------------------------ args --- TARGET="" APPCAST="" OFFLINE="" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; -v|--version) printf 'hawser-check %s\n' "$VERSION"; exit 0 ;; --appcast) APPCAST="${2:-}"; [[ -n "$APPCAST" ]] || die "--appcast needs a URL."; shift 2 ;; --appcast=*) APPCAST="${1#*=}"; shift ;; --offline) OFFLINE=1; shift ;; -*) die "Unknown option: $1. Run with --help." ;; *) [[ -z "$TARGET" ]] || die "Only one path at a time."; TARGET="$1"; shift ;; esac done [[ "$(uname -s)" == "Darwin" ]] || die "This only runs on macOS: it asks codesign, spctl and stapler for their verdicts." [[ -n "$TARGET" ]] || { usage; exit 2; } # Tab completion appends a slash to a directory, and a .app IS a directory, so # the most natural way to type the path produced "Give me a .dmg, a .app or a # .zip" on a perfectly valid one. TARGET="${TARGET%/}" [[ -e "$TARGET" ]] || die "No such file: $TARGET" command -v codesign >/dev/null 2>&1 || die "codesign not found. Install the Xcode command line tools: xcode-select --install" # ----------------------------------------------------------- open target --- # A DMG has to be mounted to see the app inside it. Mount read-only, without # browsing (so Finder does not open a window on someone's machine), and always # detach again even if a check below dies unexpectedly. MOUNTPOINT="" WORKDIR="" cleanup() { [[ -n "$MOUNTPOINT" ]] && hdiutil detach "$MOUNTPOINT" -quiet -force >/dev/null 2>&1 [[ -n "$WORKDIR" ]] && rm -rf "$WORKDIR" return 0 } trap cleanup EXIT INT TERM DMG="" APP="" case "$TARGET" in *.dmg) DMG="$TARGET" ;; *.app) APP="${TARGET%/}" ;; *.zip) WORKDIR=$(mktemp -d 2>/dev/null) || die "Could not create a temporary directory." ditto -x -k "$TARGET" "$WORKDIR" >/dev/null 2>&1 || die "Could not unzip $TARGET." APP=$(find "$WORKDIR" -maxdepth 2 -name '*.app' -print -quit 2>/dev/null) [[ -n "$APP" ]] || die "No .app found inside $TARGET." ;; *) die "Give me a .dmg, a .app or a .zip. Got: $TARGET" ;; esac printf '\n %shawser-check %s%s %s%s%s\n' "$B" "$VERSION" "$X" "$D" "$(basename "$TARGET")" "$X" # =========================================================== the container == if [[ -n "$DMG" ]]; then section "The DMG you distribute" if codesign --verify --strict "$DMG" >/dev/null 2>&1; then pass "Signature is valid" else fail "The DMG is not validly signed" \ "Gatekeeper treats an unsigned or broken container as untrusted even when" \ "the app inside it is perfect." fi DMG_AUTH=$(codesign -dv --verbose=2 "$DMG" 2>&1 | grep '^Authority=' | head -1) case "$DMG_AUTH" in *"Developer ID Application"*) pass "Signed with a Developer ID certificate" ;; "") fail "The DMG carries no signing authority" \ "It was never signed, so there is nothing for Gatekeeper to trust." ;; *) fail "Not signed with a Developer ID certificate" \ "Found: ${DMG_AUTH#Authority=}" \ "A development or ad-hoc identity works on your Mac and nowhere else." ;; esac # This is the one people miss. Xcode notarises the app; the DMG is a separate # submission, and a DMG without its own stapled ticket opens fine on any Mac # that has already seen the app, which includes yours. if xcrun stapler validate "$DMG" >/dev/null 2>&1; then pass "Notarised, with the ticket stapled to the DMG" else fail "The DMG has no stapled notarisation ticket" \ "This is the single most common miss: notarising the app is a different" \ "submission from notarising the DMG you actually ship. Without the ticket," \ "the download fails to open on any Mac that cannot reach Apple, and opens" \ "perfectly on yours, which already has the ticket cached." fi if spctl -a -vv -t install "$DMG" 2>&1 | grep -q 'accepted'; then pass "Gatekeeper accepts it as an install" else fail "Gatekeeper refuses this DMG" \ "$(spctl -a -vv -t install "$DMG" 2>&1 | tail -1)" fi MOUNTPOINT=$(hdiutil attach "$DMG" -nobrowse -readonly -noverify -mountrandom /tmp 2>/dev/null \ | grep -o '/tmp/dmg\.[^[:space:]]*' | tail -1) if [[ -n "$MOUNTPOINT" ]]; then APP=$(find "$MOUNTPOINT" -maxdepth 2 -name '*.app' -print -quit 2>/dev/null) [[ -n "$APP" ]] || note "Mounted, but no .app inside. Skipping the app checks." else MOUNTPOINT="" note "Could not mount the DMG, so the app inside it was not checked." fi fi # ================================================================= the app == if [[ -n "$APP" ]]; then if [[ -n "$DMG" ]]; then section "The app inside it"; else section "The app"; fi # A failed deep verification has several causes that want completely # different responses, and the first version of this tool named the wrong one # for every app it met: it reported "Xcode re-signs SPM frameworks ad-hoc" at # Google Chrome, which has no SPM frameworks, when the real message was about # a Finder xattr on the local copy. A check that fails with a confident wrong # diagnosis is worse than one that does not run, so read what codesign # actually said and branch on it. DEEP_OUT=$(codesign --verify --deep --strict "$APP" 2>&1) if [[ $? -eq 0 ]]; then pass "Deep signature verification passes" else DEEP_MSG=$(tail -1 <<<"$DEEP_OUT") case "$DEEP_OUT" in *"resource fork"*|*"Finder information"*|*detritus*) # An extended attribute on THIS copy, not something the developer # shipped. Copying a bundle around in Finder is enough to add one. # Gatekeeper does not care, which is why the next check still passes; # codesign --deep does, and so does notarisation. fail "Extended attributes on this copy break a strict signature check" \ "codesign says: ${DEEP_MSG#*: }" \ "This is almost certainly an artefact of the copy on your disk rather" \ "than of the build that was shipped: a Finder xattr is enough to do it," \ "and Gatekeeper ignores it, so the Gatekeeper line below can still pass." \ "It matters when the bundle is YOURS and about to be notarised, because" \ "Apple refuses it. Clear them and check again: xattr -cr \"\$APP\"" \ "If the failure survives that, the signature itself is the problem." ;; *"not signed at all"*|*"code object is not signed"*) fail "Something nested inside the bundle is not signed" \ "codesign says: ${DEEP_MSG#*: }" \ "Every binary inside the bundle has to be signed, not just the app." \ "Xcode re-signs embedded SPM binary frameworks ad-hoc, which passes" \ "locally and is rejected once every inner binary is checked." \ "Read the whole list with: codesign --verify --deep --strict --verbose=2 \"\$APP\"" ;; *) fail "Deep signature verification fails" \ "codesign says: ${DEEP_MSG#*: }" \ "Something inside the bundle is unsigned, modified after signing, or" \ "otherwise not what the signature covers. Read the whole list with:" \ "codesign --verify --deep --strict --verbose=2 \"\$APP\"" ;; esac fi SIGN_INFO=$(codesign -dv --verbose=2 "$APP" 2>&1) APP_AUTH=$(grep '^Authority=' <<<"$SIGN_INFO" | head -1) # Three different things get called "a signed Mac app" and they live under # different rules. Reporting a Mac App Store app or one of Apple's own as # broken because it has no Developer ID staple would be this tool making # exactly the mistake it exists to catch: reading a healthy artifact against # the wrong expectation. Only the outside-the-store channel gets the full set. CHANNEL="devid" case "$APP_AUTH" in *"Developer ID Application"*) CHANNEL="devid" ;; *"Apple Mac OS Application Signing"*) CHANNEL="mas" ;; *"Software Signing"*) CHANNEL="apple" ;; "") CHANNEL="unsigned" ;; *) CHANNEL="other" ;; esac case "$CHANNEL" in devid) pass "Signed with a Developer ID certificate" ;; mas) skip "Distributed through the Mac App Store, so the checks below do not apply." note "This tool is for apps you distribute yourself. Apple signs, notarises" note "and delivers store apps, so there is nothing here for you to get wrong." ;; apple) skip "This is one of Apple's own system apps, not something you shipped." note "It ships with macOS under Apple's internal signing, so the" note "Developer ID checks below are not the rules it lives by." ;; unsigned) fail "The app carries no signing authority" \ "It was never signed. macOS refuses to open this on any Mac but the one" \ "that built it, and there is nothing for Gatekeeper to trust." ;; other) fail "Not signed with a Developer ID certificate" \ "Found: ${APP_AUTH#Authority=}" \ "A development or ad-hoc identity works on your Mac and nowhere else." ;; esac fi # Checks from here down are about distributing an app yourself. They are the # ones that fail quietly, and they only mean something for a Developer ID build. if [[ -n "$APP" && ( "$CHANNEL" == "devid" || "$CHANNEL" == "unsigned" || "$CHANNEL" == "other" ) ]]; then if grep -q 'flags=.*runtime' <<<"$SIGN_INFO"; then pass "Hardened Runtime is enabled" else fail "Hardened Runtime is missing" \ "Notarisation requires it. If this app is notarised anyway it was signed" \ "before the requirement applied, and the next submission will be refused." fi # Quiet, slow and expensive. A signature without a secure timestamp stops # validating when the certificate expires, rather than staying valid for what # it signed at the time. Nothing goes wrong until the day it does. if grep -q 'Timestamp=' <<<"$SIGN_INFO"; then pass "Secure timestamp present" else fail "No secure timestamp on the signature" \ "Signed offline, or without --timestamp. A signature with no timestamp" \ "stops being valid when your certificate expires instead of remaining" \ "valid for what it signed. Copies already on disk break years later." fi if codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q 'get-task-allow'; then fail "The debug entitlement get-task-allow survived into this build" \ "Xcode injects it for local builds. In a shipped app it means the binary" \ "can be attached to by a debugger, and Apple refuses the notarisation." else pass "No get-task-allow entitlement" fi if xcrun stapler validate "$APP" >/dev/null 2>&1; then pass "Notarised, with the ticket stapled to the app" else fail "The app has no stapled notarisation ticket" \ "It may still have been notarised: without the staple, every launch asks" \ "Apple, so it fails closed on a Mac that is offline or behind a captive" \ "portal. Yours has the answer cached and shows you nothing." fi if spctl -a -vv "$APP" 2>&1 | grep -q 'accepted'; then pass "Gatekeeper accepts it" else fail "Gatekeeper refuses this app" "$(spctl -a -vv "$APP" 2>&1 | tail -1)" fi # ---------- the binary ---------- BIN_NAME=$(plutil -extract CFBundleExecutable raw "$APP/Contents/Info.plist" 2>/dev/null) BIN="$APP/Contents/MacOS/${BIN_NAME:-}" if [[ -n "${BIN_NAME:-}" && -f "$BIN" ]]; then ARCHS=$(lipo -archs "$BIN" 2>/dev/null || echo "") if [[ "$ARCHS" == *arm64* && "$ARCHS" == *x86_64* ]]; then pass "Universal binary (Apple Silicon and Intel)" elif [[ "$ARCHS" == *arm64* ]]; then skip "Apple Silicon only. Intel Macs cannot run this (deliberate for some, a surprise for others)." elif [[ -n "$ARCHS" ]]; then fail "Intel only: $ARCHS" \ "This runs under Rosetta on Apple Silicon, or not at all if Rosetta is absent." else skip "Could not read the architectures of the main binary." fi else skip "Could not locate the main binary, so architecture was not checked." fi MINOS=$(plutil -extract LSMinimumSystemVersion raw "$APP/Contents/Info.plist" 2>/dev/null) if [[ -n "${MINOS:-}" ]]; then pass "Declares a minimum macOS: $MINOS" else fail "No LSMinimumSystemVersion in Info.plist" \ "macOS cannot warn someone on an older system before they launch it, so" \ "they get a crash or a silently broken feature instead of a clear refusal." fi # ---------- updates ---------- FEED=$(plutil -extract SUFeedURL raw "$APP/Contents/Info.plist" 2>/dev/null) EDKEY=$(plutil -extract SUPublicEDKey raw "$APP/Contents/Info.plist" 2>/dev/null) HAS_SPARKLE="" [[ -d "$APP/Contents/Frameworks/Sparkle.framework" ]] && HAS_SPARKLE=1 [[ -n "${FEED:-}${EDKEY:-}" ]] && HAS_SPARKLE=1 if [[ -z "$HAS_SPARKLE" ]]; then # Silence here used to be indistinguishable from "nothing wrong with your # updates". An app with no updater is a legitimate choice; an app whose # Sparkle lives somewhere this script does not look is a gap in the script. # Either way the reader deserves to be told which of the two happened. section "Updates" skip "No Sparkle updater found in this bundle, so none of the update checks ran." note "That is correct for an app that does not self-update. If yours does," note "its framework is somewhere this script did not look: it expects" note "Contents/Frameworks/Sparkle.framework or SUFeedURL in Info.plist." else section "Updates (Sparkle)" case "${FEED:-}" in "") fail "Sparkle is bundled but no SUFeedURL is set" \ "The updater is shipping inside your app and has nowhere to look." ;; *example.com*) fail "SUFeedURL is still the placeholder: $FEED" \ "Every copy you have shipped is checking example.com for updates." \ "There is no error anywhere; the app simply never updates." ;; http://*) fail "SUFeedURL is plain HTTP: $FEED" \ "App Transport Security blocks it, so the check fails silently." ;; *) pass "Update feed: $FEED" ;; esac case "${EDKEY:-}" in "") fail "No SUPublicEDKey in Info.plist" \ "Sparkle 2 refuses unsigned updates. With no key in the app there is" \ "nothing to verify against, and updates fail with no visible error." ;; *) pass "Update signing key present in the app" ;; esac fi fi # ============================================================== the appcast == # The feed the app already points at is checked by default. It used to be # opt-in, and that is exactly how a dead feed stayed invisible for five weeks in # the app this tool was written for: every other check passed, the summary said # "nothing here is wrong", and the one broken thing was behind a flag nobody # passed. A feed that cannot be reached is the most expensive silent failure in # this whole pipeline, so it is no longer something you have to think to ask for. if [[ -z "$APPCAST" && -z "$OFFLINE" && -n "${FEED:-}" ]]; then case "$FEED" in *example.com*|http://*) : ;; # already reported above as misconfigured *) APPCAST="$FEED" ;; esac fi if [[ -n "$APPCAST" ]]; then section "Your published update feed" if ! command -v curl >/dev/null 2>&1; then skip "curl not available, so the feed was not fetched." else FEED_BODY=$(curl -fsSL --max-time 20 "$APPCAST" 2>/dev/null) if [[ -z "$FEED_BODY" ]]; then fail "Could not fetch $APPCAST" \ "Unreachable, redirected somewhere unexpected, or empty. Sparkle sees" \ "exactly what this saw, and reports nothing to your users when it fails." else pass "Feed is reachable" ITEMS=$(count '' "$FEED_BODY") if [[ "$ITEMS" -gt 0 ]]; then pass "Feed contains $ITEMS release item(s)" SIGS=$(count 'edSignature=' "$FEED_BODY") if [[ "$SIGS" -ge "$ITEMS" ]]; then pass "Every item carries an EdDSA signature" elif [[ "$SIGS" -eq 0 ]]; then fail "No item in the feed is signed" \ "Sparkle 2 rejects every one of these updates. There is no failure" \ "dialog and no log entry your users will ever see: they simply stay" \ "on the version they have. Sparkle's own generate_appcast produces" \ "an unsigned feed without warning when it cannot find your key." else fail "Only $SIGS of $ITEMS items are signed" \ "The unsigned ones are silently rejected by every installed copy." fi else fail "The feed has no elements" \ "It parses, it returns 200, and it offers nothing. An app pointed at" \ "this will report that it is up to date forever." fi fi fi elif [[ -n "$OFFLINE" && -n "${FEED:-}" ]]; then section "Your published update feed" skip "Not checked: --offline was given." note "The feed is where silent failures live. Run without --offline when you can." fi # ================================================================= verdict == printf '\n' TOTAL=$((PASSED+FAILED)) if [[ "$FAILED" -eq 0 ]]; then printf ' %s%d of %d checks passed.%s' "$G" "$PASSED" "$TOTAL" "$X" [[ "$SKIPPED" -gt 0 ]] && printf ' %s(%d not applicable)%s' "$D" "$SKIPPED" "$X" case "${CHANNEL:-devid}" in mas|apple) printf '\n %sNothing was wrong, but nothing much was checked either: this app is not%s\n' "$D" "$X" printf ' %sdistributed the way this tool inspects. Point it at a .dmg you built.%s\n\n' "$D" "$X" ;; *) printf '\n %sNothing here is wrong. This is what a correctly shipped build looks like.%s\n\n' "$D" "$X" ;; esac exit 0 else printf ' %s%d of %d checks failed.%s\n' "$R" "$FAILED" "$TOTAL" "$X" printf ' %sMost of the failures above are invisible from where you are sitting:%s\n' "$D" "$X" printf ' %sthe app works for you and fails for the people who downloaded it.%s\n' "$D" "$X" printf '\n %shawser-check is free and always will be. If you would rather these%s\n' "$D" "$X" printf ' %sran on every release instead of when you remember: hawserkit.com%s\n\n' "$D" "$X" exit 1 fi