#!/bin/bash # fwcheck.sh - standalone verifier for repaired FileWave macOS artefacts. # # Usage: # ./fwcheck.sh /path/to/FileWave\ Client.pkg # ./fwcheck.sh /path/to/FileWave_Upgrade_Fileset_macOS__.fileset # ./fwcheck.sh /path/to/FileWave_Upgrade_Fileset_macOS__.fileset.zip # # This file has no FileWave source-tree or Python dependencies. It uses only # tools shipped with macOS (plus unzip, also supplied by macOS). # # Exit status: # 0 = current and valid # 1 = outdated or invalid # 2 = unsupported input or the check could not be completed set -uo pipefail CURRENT_TEAM="UWMR88SA8G" LEGACY_TEAM="83S2TRZ3CS" POLICY_ID="FW-SIGNING-1" CHECKER_VERSION="2026.08.02.1" TEMPORARY="" usage() { echo "usage: $(basename "$0") /path/to/Something.pkg|Something.fileset[.zip]" >&2 } print_pass() { echo "" echo "PASS - meets FileWave macOS signing remediation policy $POLICY_ID" echo " (fwcheck $CHECKER_VERSION)." } cleanup() { if [ -n "$TEMPORARY" ] && [ -d "$TEMPORARY" ]; then case "$TEMPORARY" in */fwcheck.*) rm -rf -- "$TEMPORARY" ;; esac fi } trap cleanup EXIT HUP INT TERM make_temporary_directory() { if [ -n "$TEMPORARY" ] && [ -d "$TEMPORARY" ]; then return 0 fi TEMPORARY="$(mktemp -d "${TMPDIR:-/tmp}/fwcheck.XXXXXX")" || { echo "ERROR: could not create a temporary directory." >&2 return 2 } } is_macho() { local magic magic="$(/usr/bin/od -An -N4 -tx1 "$1" 2>/dev/null | /usr/bin/tr -d ' \n')" case "$magic" in feedface|cefaedfe|feedfacf|cffaedfe|cafebabe|bebafeca|cafebabf|bfbafeca) return 0 ;; *) return 1 ;; esac } team_for_code() { /usr/bin/codesign -d --verbose=4 "$1" 2>&1 \ | /usr/bin/sed -n 's/^TeamIdentifier=//p' \ | /usr/bin/head -n 1 } # Verify every Mach-O in a NUL-delimited candidate list. The caller chooses # whether that list contains all files (filesets) or likely-code files (pkgs). verify_macho_candidates() { local candidates="$1" local verify_signatures="${2:-1}" local path magic team output local macho_count=0 local failure_count=0 while IFS= read -r -d '' path; do if ! is_macho "$path"; then continue fi macho_count=$((macho_count + 1)) team="$(team_for_code "$path")" if [ "$team" != "$CURRENT_TEAM" ]; then echo " BAD TEAM: $path" echo " found ${team:-}; expected $CURRENT_TEAM" failure_count=$((failure_count + 1)) continue fi if [ "$verify_signatures" -eq 1 ]; then if ! output="$(/usr/bin/codesign --verify --strict --all-architectures \ --verbose=2 "$path" 2>&1)"; then echo " BAD SIGNATURE: $path" echo " ${output//$'\n'/$'\n '}" failure_count=$((failure_count + 1)) fi fi done < "$candidates" MACHO_COUNT="$macho_count" MACHO_FAILURES="$failure_count" } # Bundle verification adds the resource seal checks that are not part of # verifying the Mach-O executable alone. Unsigned resource-only bundles are # skipped; every signed bundle must use the current team and verify strictly. verify_bundles_under() { local root="$1" local list="$2" local bundle team output local bundle_count=0 local failure_count=0 /usr/bin/find "$root" -type d \( \ -name '*.app' -o -name '*.framework' -o -name '*.xpc' -o \ -name '*.appex' -o -name '*.bundle' -o -name '*.plugin' \ \) -print0 > "$list" while IFS= read -r -d '' bundle; do # codesign -d fails for an unsigned, resource-only bundle. Such a # directory is not a signed-code target and is intentionally ignored. if ! /usr/bin/codesign -d --verbose=2 "$bundle" >/dev/null 2>&1; then continue fi bundle_count=$((bundle_count + 1)) team="$(team_for_code "$bundle")" if [ "$team" != "$CURRENT_TEAM" ]; then echo " BAD BUNDLE TEAM: $bundle" echo " found ${team:-}; expected $CURRENT_TEAM" failure_count=$((failure_count + 1)) continue fi if ! output="$(/usr/bin/codesign --verify --deep --strict \ --all-architectures --verbose=2 "$bundle" 2>&1)"; then echo " BAD BUNDLE SEAL: $bundle" echo " ${output//$'\n'/$'\n '}" failure_count=$((failure_count + 1)) fi done < "$list" BUNDLE_COUNT="$bundle_count" BUNDLE_FAILURES="$failure_count" } check_fileset() { local fileset="$1" local container="$fileset/Contents/FilesetContainer" local payload="$fileset/Contents/Files" local candidates if [ ! -f "$container" ] || [ ! -d "$payload" ]; then echo "NOT A FILESET: $fileset" echo " expected Contents/FilesetContainer and Contents/Files" return 2 fi echo "checking fileset: $(basename "$fileset")" if /usr/bin/grep -rqsa "$LEGACY_TEAM" "$payload"; then echo "OUTDATED - payload still contains revoked Team ID $LEGACY_TEAM." return 1 fi if ! /usr/bin/grep -qsa preflightScripts "$container"; then echo "OUTDATED - bundle-cleanup preflight script is not registered." return 1 fi make_temporary_directory || return 2 candidates="$TEMPORARY/fileset-candidates" /usr/bin/find "$payload" -type f -print0 > "$candidates" # Fileset payload files are opaque FWD blobs rather than their real # bundle hierarchy. codesign can read their CodeDirectory and Team ID, but # cannot cryptographically verify Info.plist/resource slots until FileWave # reconstructs that hierarchy. The revoked-Team and cleanup-marker tests # are therefore the reliable customer-side discriminators for a fileset. verify_macho_candidates "$candidates" 0 if [ "$MACHO_COUNT" -eq 0 ]; then echo "INVALID - no Mach-O payload files were found." return 1 fi if [ "$MACHO_FAILURES" -ne 0 ]; then echo "INVALID - $MACHO_FAILURES of $MACHO_COUNT Mach-O signatures failed." return 1 fi echo "CURRENT - all $MACHO_COUNT embedded Mach-O signatures identify Team ID $CURRENT_TEAM;" echo " no revoked identity remains and the cleanup preflight is registered." print_pass return 0 } check_package() { local package="$1" local signature expanded candidates bundle_list payload_root local notarization="not reported (possibly submitted with --no-wait)" local total_bundles=0 local failed_bundles=0 if [ ! -f "$package" ]; then echo "NOT A PACKAGE: $package" return 2 fi echo "checking package: $(basename "$package")" if ! signature="$(/usr/sbin/pkgutil --check-signature "$package" 2>&1)"; then echo "INVALID - pkgutil rejected the outer installer signature." echo " ${signature//$'\n'/$'\n '}" return 1 fi if ! /usr/bin/grep -Fq "Developer ID Installer:" <<< "$signature" \ || ! /usr/bin/grep -Fq "($CURRENT_TEAM)" <<< "$signature"; then echo "OUTDATED/INVALID - outer installer is not signed by current Team ID $CURRENT_TEAM." echo " ${signature//$'\n'/$'\n '}" return 1 fi if /usr/bin/grep -q 'Notarization:' <<< "$signature"; then notarization="$(/usr/bin/sed -n 's/^[[:space:]]*Notarization:[[:space:]]*//p' \ <<< "$signature" | /usr/bin/head -n 1)" fi make_temporary_directory || return 2 expanded="$TEMPORARY/expanded" if ! /usr/sbin/pkgutil --expand-full "$package" "$expanded" >/dev/null; then echo "ERROR - pkgutil could not expand the package." >&2 return 2 fi while IFS= read -r -d '' payload_root; do if /usr/bin/grep -rqsa "$LEGACY_TEAM" "$payload_root"; then echo "OUTDATED - package payload still contains revoked Team ID $LEGACY_TEAM." return 1 fi done < <(/usr/bin/find "$expanded" -type d -name Payload -print0) # Mach-O files are normally executable. Include common library suffixes in # case a package preserves a library without executable mode bits. candidates="$TEMPORARY/package-candidates" : > "$candidates" while IFS= read -r -d '' payload_root; do /usr/bin/find "$payload_root" -type f \( \ -perm -111 -o -name '*.dylib' -o -name '*.so' \ \) -print0 >> "$candidates" done < <(/usr/bin/find "$expanded" -type d -name Payload -print0) verify_macho_candidates "$candidates" if [ "$MACHO_FAILURES" -ne 0 ]; then echo "INVALID - $MACHO_FAILURES of $MACHO_COUNT Mach-O signatures failed." return 1 fi # Validate resource seals and nested-code requirements for signed bundles. while IFS= read -r -d '' payload_root; do bundle_list="$TEMPORARY/bundles-$total_bundles" verify_bundles_under "$payload_root" "$bundle_list" total_bundles=$((total_bundles + BUNDLE_COUNT)) failed_bundles=$((failed_bundles + BUNDLE_FAILURES)) done < <(/usr/bin/find "$expanded" -type d -name Payload -print0) if [ "$failed_bundles" -ne 0 ]; then echo "INVALID - $failed_bundles of $total_bundles signed bundles failed." return 1 fi echo "CURRENT - outer installer uses Team ID $CURRENT_TEAM." echo " $MACHO_COUNT/$MACHO_COUNT Mach-O files verify strictly in all architectures." echo " $total_bundles/$total_bundles signed bundles pass strict resource-seal verification." echo " notarization: $notarization" print_pass return 0 } input="${1:-}" input="${input%/}" if [ "$input" = "-h" ] || [ "$input" = "--help" ]; then usage exit 0 fi if [ "$input" = "--version" ]; then echo "fwcheck $CHECKER_VERSION - policy $POLICY_ID" exit 0 fi if [ -z "$input" ] || [ "$#" -ne 1 ]; then usage exit 2 fi case "$input" in *.pkg) check_package "$input" exit $? ;; *.fileset.zip) if ! command -v unzip >/dev/null 2>&1; then echo "ERROR: unzip is required to inspect a .fileset.zip archive." >&2 exit 2 fi make_temporary_directory || exit 2 if ! unzip -q "$input" -d "$TEMPORARY/unpacked"; then echo "ERROR: could not unpack $input" >&2 exit 2 fi fileset="$(/usr/bin/find "$TEMPORARY/unpacked" -type d \ -name '*.fileset' -print -quit)" if [ -z "$fileset" ]; then echo "NOT A FILESET ZIP: no .fileset directory found in $input" exit 2 fi check_fileset "$fileset" exit $? ;; *.fileset) check_fileset "$input" exit $? ;; *) echo "UNSUPPORTED: $input" usage exit 2 ;; esac