#!/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_<version>_<rev>.fileset
#   ./fwcheck.sh /path/to/FileWave_Upgrade_Fileset_macOS_<version>_<rev>.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:-<none>}; 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:-<none>}; 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 FW<number>D 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
