#!/bin/bash
# polinrider_hunt.sh
# macOS triage scanner for PolinRider / Void Dokkaebi / Contagious Interview indicators
# Covers: BeaverTail, InvisibleFerret, OtterCookie, FlexibleFerret, GolangGhost, PylangGhost
#
# Version: 1.1.0  (2026-06-05)
#
# Version history (per-release SHA-256 of THIS script; a file cannot contain its
# own hash, so the current release's hash is in the signed SHA256SUMS and gets
# frozen into the table below when the next version ships):
#   version  date        sha256 (of this script)
#   1.0.0    2026-06-05  9d29ad615cd9d423952bd3fc12cc5405294fa9aa6188747d7851bdc02dbaa8e3
#   1.0.1    2026-06-05  8ffd6f86971086b0ab065da88daf54d6978f4b321a826c00766d6f56844fe58f
#   1.0.2    2026-06-05  e81bdcf1fecdd43e478d4f8ff286bf418cdfe0112a5006af09d3435d4b277645
#   1.1.0    2026-06-05  see SHA256SUMS (current release)
#
# Recent changes:
#   - Branch scan rewritten to git rev-list blob-dedup: seconds (not hours) on huge-ref repos;
#     reads each unique blob once; coverage unchanged; deterministic.
#   - Interactive mode keeps asking for the dev folder until you give one (q / Ctrl-D to quit).
#   - Default = file scan; host/system triage is opt-in (--host) and prints [ CHECK ], not [ HIT ].
#   - Dropped 0xccfc as a standalone IOC (Unicode U+CCFC false-positive); 0xaf163278 still fires.
#
# Integrity: verify before running. Each release ships a GPG-signed SHA256SUMS.
#   check hash:  shasum -a 256 -c SHA256SUMS     (or: sha256sum -c SHA256SUMS)
#   check sig:   gpg --verify SHA256SUMS.asc SHA256SUMS
#
# By DEFAULT this scans your code only (cloned repos / dev folders) and reports
# malicious FILES as [ HIT ]. Host/system triage (LaunchAgents, processes,
# network, browser staging) is OFF by default and runs only with --host, where
# its findings print as the calmer [ CHECK ] — context to review, not proof.
#
# Usage: bash polinrider_hunt.sh --scan-root <path> [--scan-root <path>]... [--host] [--full] [--quiet]
#        bash polinrider_hunt.sh -folder <path> [--full] [--quiet]
#   A dev folder is REQUIRED: pass --scan-root (repeatable) or -folder, or answer
#   the interactive prompt. With none supplied the scanner refuses to start.
#   --host              Also run host/system triage (off by default). Its findings
#                       print as [ CHECK ], never [ HIT ].
#   --full              Implies --host and adds slow checks (unified-log; needs sudo)
#   --quiet             Suppress section headers, output findings only (SIEM-friendly)
#   --scan-root <path>  The dev folder(s) to scan, repeatable. REQUIRED unless you
#                       use -folder or answer the interactive prompt. The scanner
#                       scans ONLY what you give it; there is no autodetect (auto-
#                       scanning Documents/Desktop/Downloads was slow on iCloud).
#   -folder <path>      Single-folder mode: scan ONLY that directory and skip
#                       all host-triage sections (LaunchAgents, processes,
#                       network, browser staging, etc.). Fully non-interactive.
#                       Good for CI / ad-hoc repo scans where you only care
#                       about the source-code indicators.
#
# Branch coverage: in addition to the checked-out working tree, this scanner
# runs a git BRANCH scan (section 8b) over every branch tip (local +
# remote-tracking + tags) of every git repo under the scan roots, with the known
# dhis2-org campaign repos checked FIRST (fail-fast). Four layers per repo:
#   L0  git-grep the loader invariants across every ref tree (Vector-1 markers)
#   L1  match the known woff2-kit git blob SHAs via ls-tree (Vector-2, exact)
#   L2  SHA-256 of config/.vscode/font blobs vs the file-hash IOC list (loaders)
#   L3  masquerade + .vscode structural on those blobs (rotated kits)
# This catches the 2026-06-03 force-push vector, where the payload lived only on
# non-default branches (e.g. cache-cleaner-app: default HEAD clean, 80/94
# branches carried the woff2 kit). Read-only — no checkout, no fetch, no network,
# no hooks. Requires `git`; skipped with a warning if git is absent.
#
# First prompt: on an interactive run the script asks for your development folder
# (where you clone / download repos); that folder is the ONLY thing scanned (there
# is no autodetect). The prompt is skipped under --quiet, --scan-root, -folder, or
# a non-TTY stdin (cron / MDM / CI); there you MUST pass --scan-root or the scan
# refuses to start.
#
# Exit codes: 0 = clean, 1 = findings, 2 = error
#
# DESIGNED TO BE READ-ONLY. This script does not modify, quarantine,
# or kill anything. It only reports. Containment is a separate decision.
#
# Run as the affected user first, then optionally re-run with sudo for
# system-wide LaunchDaemons. Both passes are useful.
#
# COMPANION TOOLS (recommended — this script is campaign-specific and
# only catches PolinRider/Void Dokkaebi indicators it knows about. The
# tools below are generic and catch what this script does not):
#
#   KnockKnock — enumerates every persistent item on macOS (LaunchAgents,
#                LaunchDaemons, login items, kexts, browser extensions,
#                cron, etc.) with code-signing info. Free, GUI.
#                Run before AND after this script for a baseline diff.
#                https://objective-see.org/products/knockknock.html
#
#   LuLu       — outbound-network firewall. Useful for ongoing monitoring
#                after triage; alerts on new outbound connections.
#                https://objective-see.org/products/lulu.html
#
#   Both tools are from Objective-See (Patrick Wardle), the canonical
#   independent macOS security tool author. No affiliation; just good tools.

# Re-exec under bash if started through another shell. macOS defaults to zsh, so
# `zsh script.sh` / `sudo zsh script.sh` is an easy mistake — and this script
# relies on bash-only features (shopt nullglob, arrays); zsh aborts on the first
# unmatched glob with "no matches found". The shebang already covers `./script`
# and `bash script`; this covers the explicit-other-shell case.
if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@"; fi

set -u
export LC_ALL=C   # pin byte-order collation for every child sort/awk/grep (deterministic)

HUNT_VERSION="1.1.0"
FULL=0
HOST=0
QUIET=0
FOLDER=""
EXTRA_ROOTS=()
PRIMARY_ROOTS=()   # the user's own dev folder(s) — scanned FIRST (see early prompt)
while [ $# -gt 0 ]; do
    case "$1" in
        --full)        FULL=1; HOST=1; shift ;;
        --host)        HOST=1; shift ;;
        --quiet)       QUIET=1; shift ;;
        --scan-root)
            shift
            if [ -z "${1:-}" ]; then
                echo "ERROR: --scan-root requires a path argument" >&2
                exit 2
            fi
            EXTRA_ROOTS+=("$1"); shift ;;
        --scan-root=*) EXTRA_ROOTS+=("${1#--scan-root=}"); shift ;;
        -folder)
            shift
            if [ -z "${1:-}" ]; then
                echo "ERROR: -folder requires a path argument" >&2
                exit 2
            fi
            FOLDER="$1"; shift ;;
        -folder=*) FOLDER="${1#-folder=}"; shift ;;
        -h|--help)
            # Print the documentation header (lines 2..first non-comment line)
            sed -n '2,/^[^#]/{/^[^#]/!p;}' "$0"
            exit 0
            ;;
        *)
            echo "ERROR: unknown argument: $1" >&2
            echo "Run with --help for usage." >&2
            exit 2 ;;
    esac
done

if [ -n "$FOLDER" ]; then
    if [ ! -d "$FOLDER" ]; then
        echo "ERROR: -folder is not a directory: $FOLDER" >&2
        exit 2
    fi
fi

# When invoked via sudo, $HOME points at /var/root which is almost always
# empty on a developer machine. Resolve back to the invoking user's home so
# every $HOME-scoped check (LaunchAgents, drop dirs, browser staging, recent
# plists, source scan) runs against the actual developer's tree. Without this,
# `sudo ./polinrider_hunt.sh` would silently miss everything in $HOME and
# falsely report "clean" — the most damaging possible failure mode for a
# triage tool.
SCAN_USER="$(whoami)"
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
    _user_home=$(eval echo "~$SUDO_USER" 2>/dev/null)
    if [ -n "$_user_home" ] && [ -d "$_user_home" ]; then
        HOME="$_user_home"
        SCAN_USER="$SUDO_USER"
    fi
fi

FINDINGS=0   # file-scan [ HIT ] count (malicious files) — drives the exit code
CHECKS=0     # host-triage [ CHECK ] count (machine observations) — advisory only
TMPDIR_HUNT="$(mktemp -d -t polinrider_hunt.XXXXXX)"
trap 'rm -rf "$TMPDIR_HUNT"' EXIT

# Consolidated per-file scan script: handles every Tier-2 structural rule plus
# the T3 long-line and T3 config-with-payload-appended heuristics in a single
# awk pass. Replaces ~10 separate grep -E -q calls plus 2 separate awk calls.
# Emits one flag tag per detected condition; the calling bash code prints the
# matching hit message.
AWK_SCAN_SCRIPT="$TMPDIR_HUNT/scan_one.awk"
cat > "$AWK_SCAN_SCRIPT" <<'AWK_EOF'
BEGIN {
    # T2 shuffle-cipher decoder (3-feature co-occurrence)
    seed_pat  = "e[[:space:]]*=[[:space:]]*\\([[:space:]]*s[[:space:]]*\\+[[:space:]]*w[[:space:]]*\\)[[:space:]]*%[[:space:]]*[0-9][0-9][0-9][0-9]+"
    swap_pat  = "g[[:space:]]*\\[[[:space:]]*t[[:space:]]*\\][[:space:]]*=[[:space:]]*g[[:space:]]*\\[[[:space:]]*p[[:space:]]*\\]"
    arith_pat = "=[[:space:]]*e[[:space:]]*\\*[[:space:]]*\\([[:space:]]*j[[:space:]]*\\+[[:space:]]*[0-9]+[[:space:]]*\\)[[:space:]]*\\+[[:space:]]*\\([[:space:]]*e[[:space:]]*%[[:space:]]*[0-9]+[[:space:]]*\\)"

    # T2 detached node -e spawn
    spawn_pat    = "spawn[[:space:]]*\\([[:space:]]*[\"']node[\"'][[:space:]]*,[[:space:]]*\\[[[:space:]]*[\"']-e[\"']"
    detached_pat = "detached[[:space:]]*:[[:space:]]*true"
    stdio_pat    = "stdio[[:space:]]*:[[:space:]]*[\"']ignore[\"']"

    # T2 blockchain dead-drop chain
    tron_pat = "(api\\.trongrid\\.io|fullnode\\.mainnet\\.aptoslabs\\.com)"
    bsc_pat  = "bsc-(dataseed|rpc)"
    eth_pat  = "eth_getTransactionByHash"

    # T2 indirect Function-constructor reach
    ctor_pat = "[\"']\\.constructor\\.constructor|\\[[[:space:]]*[\"']constructor[\"'][[:space:]]*\\][[:space:]]*\\[[[:space:]]*[\"']constructor[\"']"

    # T3 long obfuscated line threshold
    LONG = 1500

    # T2 config-with-payload-appended state machine
    exports_seen = 0
    blanks = 0
}

{
    if (length($0) > LONG) { _lt=$0; _lw=gsub(/[ \t]/,"",_lt); if (_lw*10 < length($0)) f_long = 1 }

    if (match($0, seed_pat))     f_seed     = 1
    if (match($0, swap_pat))     f_swap     = 1
    if (match($0, arith_pat))    f_arith    = 1
    if (match($0, spawn_pat))    f_spawn    = 1
    if (match($0, detached_pat)) f_detached = 1
    if (match($0, stdio_pat))    f_stdio    = 1
    if (match($0, tron_pat))     f_tron     = 1
    if (match($0, bsc_pat))      f_bsc      = 1
    if (match($0, eth_pat))      f_eth      = 1
    if (match($0, ctor_pat))     f_ctor     = 1

    # config_with_payload_appended: module.exports = ... then 5+ blank lines, then IIFE
    if (match($0, /^[[:space:]]*module\.exports[[:space:]]*=/)) {
        exports_seen = 1
        blanks = 0
        next
    }
    if (exports_seen) {
        if ($0 ~ /^[[:space:]]*$/) {
            blanks++
        } else if (match($0, /\([[:space:]]*function[[:space:]]*\(/)) {
            if (blanks >= 5) f_payload_appended = 1
            exports_seen = 0
        } else {
            blanks = 0
        }
    }
}

END {
    if (f_long)                                   print "LONG"
    if (f_seed && f_swap && f_arith)              print "SHUFFLE"
    if (f_spawn && f_detached && f_stdio)         print "DETACHED_SPAWN"
    if (f_tron && f_bsc && f_eth)                 print "DEAD_DROP"
    if (f_ctor)                                   print "CTOR_EVAL"
    if (f_payload_appended)                       print "PAYLOAD_APPENDED"
}
AWK_EOF

# ---------- output helpers ----------
# Progress indicator: spinner + scanned-file count on stderr. Active only when
# stderr is a TTY and --quiet is off (so log files and SIEM ingest stay clean).
PROGRESS_ENABLED=0
if [ "$QUIET" = 0 ] && [ -t 2 ]; then
    PROGRESS_ENABLED=1
fi
PROGRESS_COUNT=0
PROGRESS_SPINNER='|/-\'
PROGRESS_IDX=0

progress_tick() {
    [ "$PROGRESS_ENABLED" = 0 ] && return 0
    PROGRESS_COUNT=$((PROGRESS_COUNT + 1))
    # Advance the spinner glyph every 5 files so it visibly rotates at any speed
    if [ $((PROGRESS_COUNT % 5)) -eq 0 ]; then
        PROGRESS_IDX=$(((PROGRESS_IDX + 1) % 4))
    fi
    local sp=${PROGRESS_SPINNER:$PROGRESS_IDX:1}
    local f="$1"
    # Truncate the path so the progress line fits on a typical 80-col terminal
    if [ ${#f} -gt 60 ]; then
        f="...${f: -57}"
    fi
    # \r returns to column 0; \033[K clears to end of line
    printf '\r[%s] scanned %d files  %s\033[K' "$sp" "$PROGRESS_COUNT" "$f" >&2
}

progress_clear() {
    [ "$PROGRESS_ENABLED" = 0 ] && return 0
    printf '\r\033[K' >&2
}

section()  { [ "$QUIET" = 0 ] && printf '\n========== %s ==========\n' "$*"; }
ok()       { [ "$QUIET" = 0 ] && printf '  [ OK ] %s\n' "$*"; }
hit()      { progress_clear; printf '  [ HIT ] %s\n' "$*"; FINDINGS=$((FINDINGS + 1)); }
# Host-triage advisory: a machine/system observation, NOT a malicious-file
# signature. Calmer prefix so [ HIT ] stays reserved for the file scanners.
check()    { progress_clear; printf '  [ CHECK ] %s\n' "$*"; CHECKS=$((CHECKS + 1)); }
warn()     { [ "$QUIET" = 0 ] && printf '  [WARN ] %s\n' "$*"; }
info()     { [ "$QUIET" = 0 ] && printf '  [INFO ] %s\n' "$*"; }

# ---------- environment sanity ----------
if [ "$(uname)" != "Darwin" ]; then
    echo "ERROR: this script targets macOS." >&2
    exit 2
fi

[ "$QUIET" = 0 ] && {
    if [ -n "$FOLDER" ]; then
        echo "PolinRider / Contagious Interview macOS hunt v$HUNT_VERSION (single-folder mode)"
    else
        echo "PolinRider / Contagious Interview macOS hunt v$HUNT_VERSION"
    fi
    if [ "$SCAN_USER" != "$(whoami)" ]; then
        echo "Host: $(hostname)   Run-as: $(whoami) (sudo)   Scan-as: $SCAN_USER"
    else
        echo "Host: $(hostname)   User: $(whoami)"
    fi
    if [ -n "$FOLDER" ]; then
        echo "Scan target: $FOLDER   Date: $(date)"
    else
        echo "Scan home: $HOME   Date: $(date)"
    fi
    echo "macOS: $(sw_vers -productVersion)   Arch: $(uname -m)"
    if [ -z "$FOLDER" ]; then
        [ "$(id -u)" -eq 0 ] && echo "Running as root (system-wide checks enabled)" \
                           || echo "Running as user (run again with sudo for system LaunchDaemons)"
    fi
}

# ===================================================================
# FIRST QUESTION: where the operator clones / downloads repos.
# ===================================================================
# Asked up front (before any scanning) so the operator answers once and walks
# away. Whatever they name is the ONLY thing scanned (there is no autodetect), so
# it must be the folder that holds their cloned repos. Gated to preserve every
# non-interactive contract:
#   --quiet (SIEM), non-TTY stdin (cron/MDM/Jamf/CI, which would hang on read),
#   --scan-root provided (operator already declared their layout), -folder mode.
if [ -z "$FOLDER" ] && [ "$QUIET" = 0 ] && [ -t 0 ] && [ "${#EXTRA_ROOTS[@]}" -eq 0 ]; then
    printf '\n'
    printf '  Where is your development folder (where you clone / download repos)?\n'
    printf '  This is the ONLY thing scanned. Enter a path; add several (one per\n'
    printf '  line) and press Enter on an empty line to start. Type q to quit.\n'
    printf '  (non-interactive equivalent: --scan-root <path>, repeatable)\n'
    # Keep asking until at least one valid dev folder is given: an empty line with
    # nothing collected yet RE-PROMPTS rather than falling through to a scan-nothing
    # exit. q (or EOF / Ctrl-D) quits without scanning. Once one or more folders are
    # collected, an empty line means "done, start scanning".
    _prompt='  [INPUT] dev folder (path, or q to quit) > '
    while true; do
        printf '%s' "$_prompt"
        IFS= read -r _dev_root || { printf '\n'; break; }   # EOF (Ctrl-D) -> stop asking
        case "$_dev_root" in
            q|Q|quit|QUIT)
                echo "  Aborted: no dev folder given; nothing scanned." >&2
                exit 2
                ;;
            '')
                [ "${#PRIMARY_ROOTS[@]}" -gt 0 ] && break
                warn "Enter at least one dev folder to scan, or q to quit."
                continue
                ;;
        esac
        # Expand a leading ~ (bash does not expand it in read-back values).
        case "$_dev_root" in "~"|"~/"*) _dev_root="$HOME${_dev_root#\~}" ;; esac
        if [ -d "$_dev_root" ]; then
            PRIMARY_ROOTS+=("$_dev_root")
            ok "Will scan: $_dev_root"
            _prompt='  [INPUT] another dev folder? (path, or Enter to start) > '
        else
            warn "Not a directory (skipping): $_dev_root"
        fi
    done
fi

# Host-triage sections (1-7) and the unified-log search describe the MACHINE,
# not your code, and were causing confusion (lots of [ HIT ]-looking noise on
# normal Macs). They are OFF by default — they run only with --host (or --full),
# and never in -folder mode. The source-scan + npm sections (8-9), which find
# malicious FILES and emit [ HIT ], always run.
if [ "$HOST" = 1 ] && [ -z "$FOLDER" ]; then

# ===================================================================
# 1. KNOWN-BAD LaunchAgent / LaunchDaemon plist names
# ===================================================================
section "Known-bad LaunchAgents / LaunchDaemons"

# Files to check, plus glob patterns. Names from public reporting:
# Jamf, SentinelOne, Abstract Security, OSM, Trend Micro 2024-2026.
KNOWN_BAD_PLISTS=(
    "$HOME/Library/LaunchAgents/com.avatar.update.wake.plist"
    "$HOME/Library/LaunchAgents/com.drive.plist"
    "$HOME/Library/LaunchAgents/com.zoom.plist"
    "$HOME/Library/LaunchAgents/com.camdrive.plist"
    "$HOME/Library/LaunchAgents/com.apple.upd.plist"
    "$HOME/Library/LaunchAgents/com.apple.questd.plist"
    "$HOME/Library/LaunchAgents/com.apple.secd.plist"
    "$HOME/Library/LaunchAgents/.com.apple.upd.plist"
)
for p in "${KNOWN_BAD_PLISTS[@]}"; do
    [ -f "$p" ] && check "Known-bad plist exists: $p"
done

# Glob patterns (the actor randomizes the suffix on driver*)
shopt -s nullglob 2>/dev/null
for p in "$HOME"/Library/LaunchAgents/com.driver*.plist; do
    check "Driver-pattern plist (FlexibleFerret signature): $p"
done
shopt -u nullglob 2>/dev/null

# Genuine Zoom uses us.zoom.ZoomDaemon.plist; com.zoom.* in user LaunchAgents is suspicious
for p in "$HOME"/Library/LaunchAgents/com.zoom*.plist; do
    [ -f "$p" ] && check "Suspicious com.zoom.* plist (genuine Zoom uses us.zoom.*): $p"
done

[ $CHECKS -eq 0 ] && ok "No known-bad plist names found"

# ===================================================================
# 2. LaunchAgents/Daemons whose ProgramArguments point to suspicious paths
# ===================================================================
section "LaunchAgents pointing at suspicious paths"

# Drop locations seen across reporting:
#   /var/tmp/CDrivers, /var/tmp/WebCam, /tmp/<binary|script>, /Users/Shared/, ~/.n2, ~/.npl
#
# /tmp/ matches only EXECUTABLE-looking targets (scripts, .app bundles, common
# RAT script extensions), not arbitrary log/cache files. Real campaign drops
# referenced from plists are like /tmp/versus.app, /tmp/MediaPatcher.app,
# /tmp/foo.sh — never /tmp/something.log. The non-letter boundary excludes
# /var/tmp/ (the 'r' in 'var' fails the [^a-z] test) so it doesn't double-fire
# with the more specific /var/tmp/* patterns above.
SUSPICIOUS_PATH_RE='/var/tmp/CDrivers|/var/tmp/WebCam|/var/tmp/[^[:space:]]*\.sh|(^|[^a-z])/tmp/[^[:space:]<>"]*\.(sh|app|py|pl|js|bash|command)|/Users/Shared/|/\.n2/|/\.npl|/\.config/\.n2'

scan_plist_dir() {
    local dir="$1"
    [ -d "$dir" ] || return 0
    # find each plist, dump it, and grep for paths
    while IFS= read -r -d '' plist; do
        # plutil -convert xml1 -o - reads both binary and xml plists
        local xml
        xml=$(plutil -convert xml1 -o - "$plist" 2>/dev/null) || continue

        # Look in ProgramArguments / Program for suspicious paths
        if printf '%s' "$xml" | grep -E "$SUSPICIOUS_PATH_RE" >/dev/null; then
            local match
            match=$(printf '%s' "$xml" | grep -oE "(/var/tmp/[^<[:space:]]+|/tmp/[^<[:space:]]+|/Users/Shared/[^<[:space:]]+|[^<[:space:]]*\.n2[^<[:space:]]*|[^<[:space:]]*\.npl[^<[:space:]]*)" | head -3 | tr '\n' ' ')
            check "Plist references suspicious path: $plist  ->  $match"
        fi

        # Look for plists that runAtLoad and execute via /bin/sh -c, /bin/bash -c, or curl
        if printf '%s' "$xml" | grep -E '(curl|wget).*(http|sh -c|bash -c).*\|' >/dev/null; then
            check "Plist contains curl|sh / wget|sh pattern: $plist"
        fi
    done < <(find "$dir" -maxdepth 2 -type f -name '*.plist' -print0 2>/dev/null)
}

scan_plist_dir "$HOME/Library/LaunchAgents"
# /Library/LaunchAgents and /Library/LaunchDaemons are world-readable on stock
# macOS; per-file ACLs may restrict individual plists but the directory walks.
# Don't gate on `id -u` — scan whatever is readable. find inside scan_plist_dir
# silences EACCES errors via 2>/dev/null. Re-running with sudo will catch the
# stragglers that are individually unreadable to a non-root user.
[ -r "/Library/LaunchAgents" ]  && scan_plist_dir "/Library/LaunchAgents"
[ -r "/Library/LaunchDaemons" ] && scan_plist_dir "/Library/LaunchDaemons"

# ===================================================================
# 3. Filesystem drop locations
# ===================================================================
section "Known drop directories"

DROP_PATHS=(
    "/var/tmp/CDrivers"
    "/var/tmp/WebCam"
    "/tmp/versus.app"
    "/tmp/MediaPatcher.app"
    "/tmp/InstallerAlert.app"
    "$HOME/.n2"
    "$HOME/.npl"
    "$HOME/.config/.n2"
    "$HOME/.pyp"
)
for d in "${DROP_PATHS[@]}"; do
    if [ -e "$d" ]; then
        check "Drop location exists: $d ($(stat -f '%Sm' "$d" 2>/dev/null))"
        [ -d "$d" ] && [ "$QUIET" = 0 ] && find "$d" -maxdepth 1 -mindepth 1 -exec ls -ld {} \; 2>/dev/null | sed 's/^/         /'
    fi
done

# Things in /var/tmp that look like staging dirs: short ascii names with .sh inside
if [ -d /var/tmp ]; then
    while IFS= read -r d; do
        # any subdirectory in /var/tmp containing a .sh file modified in last 90 days
        if find "$d" -maxdepth 1 -name '*.sh' -mtime -90 -print -quit 2>/dev/null | grep -q .; then
            check "/var/tmp/ subdir contains recent shell script: $d"
        fi
    done < <(find /var/tmp -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
fi

# ===================================================================
# 4. Suspicious node processes (the loader's signature spawn)
# ===================================================================
section "Detached node processes"

# The PolinRider loader spawns: node -e "<obfuscated>"
# In ps, this appears as a node process with a long -e argument and
# parent PID 1 (re-parented to launchd after detach).
ps_out=$(ps -axo pid,ppid,user,etime,command 2>/dev/null)

# node -e <something> processes
node_e_procs=$(printf '%s\n' "$ps_out" | awk '/[n]ode -e/' )
if [ -n "$node_e_procs" ]; then
    while IFS= read -r line; do
        [ -z "$line" ] && continue
        check "node -e process running: $line"
    done <<< "$node_e_procs"
else
    ok "No 'node -e' processes running"
fi

# any node process with PPID 1 (orphaned/detached) is unusual on a workstation
orphan_node=$(printf '%s\n' "$ps_out" | awk '$2 == 1 && /[n]ode/')
if [ -n "$orphan_node" ]; then
    while IFS= read -r line; do
        [ -z "$line" ] && continue
        check "Detached node process (PPID=1): $line"
    done <<< "$orphan_node"
fi

# Python processes running scripts from suspicious paths
sus_python=$(printf '%s\n' "$ps_out" | awk '/[p]ython/' | grep -E '/var/tmp/|/\.n2/|/\.npl|/tmp/[^[:space:]]+\.py' || true)
if [ -n "$sus_python" ]; then
    while IFS= read -r line; do
        [ -z "$line" ] && continue
        check "Python process from suspicious path: $line"
    done <<< "$sus_python"
fi

# ===================================================================
# 5. Network indicators - active connections to known C2
# ===================================================================
section "Network connections"

# BeaverTail / InvisibleFerret signature ports: 1224, 1244
if command -v lsof >/dev/null 2>&1; then
    # Anchor :1224 / :1244 to a non-digit boundary so ephemeral source ports
    # like :12240-:12249 or :12440-:12449 (very common) don't false-positive.
    sig_port=$(lsof -nP -iTCP -sTCP:ESTABLISHED 2>/dev/null \
        | awk '($0 ~ /:1224/ && $0 !~ /:1224[0-9]/) || ($0 ~ /:1244/ && $0 !~ /:1244[0-9]/)')
    if [ -n "$sig_port" ]; then
        while IFS= read -r line; do
            check "Established connection on signature port (1224/1244): $line"
        done <<< "$sig_port"
    else
        ok "No active connections on TCP 1224/1244"
    fi

    # Any node process holding network sockets
    node_net=$(lsof -nP -iTCP 2>/dev/null | awk '/^node/')
    if [ -n "$node_net" ] && [ "$QUIET" = 0 ]; then
        info "node processes with TCP sockets (review for unexpected ones):"
        printf '%s\n' "$node_net" | sed 's/^/         /' | head -20
    fi
else
    warn "lsof not available — skipping connection check"
fi

# /etc/hosts manipulation is the more reliable signal anyway —
# DNS cache on macOS is opaque without root and tooling that varies
# by macOS version.

# /etc/hosts manipulation
if grep -E '(trongrid|aptoslabs|bsc-dataseed|bsc-rpc\.publicnode|lianxinxiao|callservice)' /etc/hosts 2>/dev/null; then
    check "/etc/hosts contains entries for known C2 domains (review above)"
fi

# ===================================================================
# 6. Browser data access markers (BeaverTail's main behavior)
# ===================================================================
section "Browser data access"

# Chrome profile dirs the malware reads from
# Suspicious copies of browser-credential files outside their normal directory
# (BeaverTail copies these into staging dirs before exfil)
search_roots=()
for d in /var/tmp /tmp "$HOME/.n2" "$HOME/.npl" "$HOME/Documents" "$HOME/Downloads"; do
    [ -d "$d" ] && search_roots+=("$d")
done
suspicious_copies=""
if [ ${#search_roots[@]} -gt 0 ]; then
    # Defense in depth: skip cloned-repo dependency / VCS dirs. A package
    # fixture under Downloads/some-repo/node_modules/ that happens to be
    # literally named "Login Data" or "Cookies" would otherwise trip this.
    suspicious_copies=$(find "${search_roots[@]}" \
        \( -path '*/node_modules/*' -o -path '*/.git/*' \) -prune -o \
        \( -name 'Login Data*' -o -name 'Cookies*' -o -name 'Local State*' -o -name 'login.keychain*' \) \
        -print 2>/dev/null)
fi
if [ -n "$suspicious_copies" ]; then
    while IFS= read -r f; do
        check "Browser-credential file in unusual location: $f"
    done <<< "$suspicious_copies"
else
    ok "No copied browser credential files in suspicious locations"
fi

# ===================================================================
# 7. Recent modifications in LaunchAgent dirs (last 90 days)
# ===================================================================
section "Recently modified persistence files (last 90 days)"

agent_dirs=("$HOME/Library/LaunchAgents")
[ -r "/Library/LaunchAgents" ]  && agent_dirs+=("/Library/LaunchAgents")
[ -r "/Library/LaunchDaemons" ] && agent_dirs+=("/Library/LaunchDaemons")

recent=$(find "${agent_dirs[@]}" -type f -name '*.plist' -mtime -90 2>/dev/null)
if [ -n "$recent" ]; then
    info "Recently modified plists (review each — not all are malicious):"
    while IFS= read -r f; do
        printf '         %s  %s\n' "$(stat -f '%Sm' "$f" 2>/dev/null)" "$f"
    done <<< "$recent"
else
    ok "No LaunchAgents modified in last 90 days"
fi

elif [ -z "$FOLDER" ] && [ "$QUIET" = 0 ]; then
    info "Host/system checks skipped — file scan only. Re-run with --host to also"
    info "check this machine (LaunchAgents, processes, network, browser staging)."
fi  # end of host-triage sections gate (off unless --host; folder mode skips)

# ===================================================================
# 8. Search files for the loader's invariant strings
# ===================================================================
section "Loader invariants in project files"

# These strings appear in PolinRider obfuscated payloads (both variants).
# Mirrors scanner/iocs/* — keep in sync when adding new IOCs there.
INVARIANTS=(
    # T1 obfuscation substitution alphabets
    "rmcej%otb%"
    "Cot%3t=shtP"
    # T1 second-stage (sfL) decoder alphabet — reused across school-licensing-app
    # (2024) and dhis2/app-management-app jest.config.js (2026-06-03)
    "wuqktamceigynzbosdctpusocrjhrflovnxrt"
    # T1 obfuscation identifiers (literal names reused across samples)
    '_$_1e42'
    '_$_ccfc'
    '_$af163278'
    # T1 hex-literal form of the af163278 magic constant. The 0x1e42 AND 0xccfc
    # siblings are omitted: U+1E42 and U+CCFC are real Unicode code points, so the
    # bare hex strings false-positive on legit data (e.g. Go x/text collation
    # tables). The distinctive _$_1e42 / _$_ccfc identifier forms above still fire.
    '0xaf163278'
    # T1 runtime global slot markers (version stamps)
    "global\\['!'\\]"
    "global\\['_V'\\]"
    # T1 throttle markers
    "global\\['_p_t'\\]"
    "global\\['_t_t'\\]"
    "global\\['_t_c'\\]"
    "global\\['_t_0'\\]"
    # T1 TRON wallets (blockchain dead-drop staging)
    "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP"
    "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG"
    # T1 Aptos transaction hashes (fallback payload pointers)
    "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e"
    "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3"
    # T1 XOR keys (stage-2 decryption) — regex-escaped for grep -E
    "2\\[gWfGj;<:-93Z\\^C"
    "m6:tTh\\^D\\)cBz\\?NM\\]"
    # T2 blockchain endpoints (legitimate RPCs; co-occurrence is the signal)
    "api.trongrid.io"
    "bsc-dataseed.binance.org"
    "bsc-rpc.publicnode.com"
    "fullnode.mainnet.aptoslabs.com"
    # T2 RPC method used in the dead-drop fetch chain
    "eth_getTransactionByHash"
    # T1 operator paste-template artifact (100% co-occurrence with loader)
    "flo-ct-flo360"
)

# Build a single regex for grep -E
INVARIANT_RE=$(IFS='|'; echo "${INVARIANTS[*]}")

# Perl-flavoured copy of the same regex for the branch-scan L0 reader (section 8b).
# The patterns are literal IOC strings (regex-escaped for grep -E), so every '$' and
# '@' in them is meant literally — e.g. the obfuscation identifier _$_1e42. In a Perl
# pattern a bare '$' is an end-of-string anchor and '@' starts array interpolation, so
# without escaping, _$_1e42 / _$_ccfc / _$af163278 would silently never match. Escape
# both; nothing in INVARIANTS uses '$'/'@' as a metacharacter, so this is loss-free.
INVARIANT_RE_PERL=${INVARIANT_RE//\$/\\$}
INVARIANT_RE_PERL=${INVARIANT_RE_PERL//@/\\@}

# SHA-256 hashes of files confirmed to carry the PolinRider loader. Mirrors
# scanner/iocs/file_sha256.txt. Byte-identical match → unambiguous compromise.
KNOWN_BAD_HASHES=(
    "faba2942385b52b2d3755a1a3d8fcafa4a768d64b21adbb6629870e20503b288"
    "dd21574b5934df98d96844c46094146712754fca5794013a16fc0d8c2d7c68a6"
    "e48083abd6343e372fc08b3c181148e865f616fa4274a6cd9cd7d3b95a4d60c1"
    "13e9a3c41e038bf9d8fcb0831305819819e4f7f4452bc20a04b9bf2756ee22e8"
    "f5c6be4753d6613c97f1b10c4d93a5d97a8f4fb21eb13da0ed04b23a8a61c2f6"
    "5de95b5cb97e43dd997fae33c657c00c42e2e6dd931cdc0108baa90a6a311a5f"
    "c0450d71e0b6d06f4d02177c2e7f59444ab368676a807b1e5ba0697ac2ad1859"
    # jest.config.js loaders (2026-06-03): app-management-app @4e12ba7, capture-app @85c9e42
    "3e0c1cd3c588df5a4eb9e250056a86125845f1ab3d6ede3745fa677133d27adb"
    "6f7efc95849eb52cd653dfe4b2bebe13b8e827f228c255d0ad536c26b9eb6ef3"
)
HASH_RE=$(IFS='|'; echo "${KNOWN_BAD_HASHES[*]}")

# Scan common project locations. Skip node_modules (full of false positives
# and we'd rather flag the parent project than each match inside).
#
# Common dev-folder names across macOS users: Apple's "Developer" (Xcode),
# capitalised Projects, plus the lowercase variants developers actually use.
# Add yours via `--scan-root <path>` or by running the script from inside
# your dev tree — $PWD is auto-included if it's under $HOME.
if [ -n "$FOLDER" ]; then
    # Folder mode: scan ONLY the supplied directory. No autodetect, no PWD
    # auto-include, no interactive prompt.
    SCAN_ROOTS=("$FOLDER")
else
    # Default mode: scan ONLY the dev folder(s) the user explicitly supplied (the
    # interactive prompt above, or --scan-root). There is deliberately NO
    # autodetect and no $PWD auto-include. Implicitly walking Documents / Desktop /
    # Downloads pulled in large iCloud-synced trees and forced a network download
    # per offloaded (dataless) file, which is the dominant cause of multi-hour
    # scans. If the user gave no dev folder, refuse to start rather than guess.
    SCAN_ROOTS=()
    [ "${#PRIMARY_ROOTS[@]}" -gt 0 ] && SCAN_ROOTS+=("${PRIMARY_ROOTS[@]}")
    [ "${#EXTRA_ROOTS[@]}"   -gt 0 ] && SCAN_ROOTS+=("${EXTRA_ROOTS[@]}")

    if [ "${#SCAN_ROOTS[@]}" -eq 0 ]; then
        echo "ERROR: no dev folder specified. The scanner will not guess." >&2
        echo "  Tell it where your repos live (this is the ONLY thing scanned):" >&2
        echo "    bash $0 --scan-root ~/dev          (your dev folder; repeatable)" >&2
        echo "    bash $0 -folder /path/to/one/repo  (scan a single folder)" >&2
        echo "  Auto-scanning Documents/Desktop/Downloads is slow on iCloud and out of scope." >&2
        exit 2
    fi

    # Order-preserving dedup (supplied roots may repeat). bash 3.2 safe.
    _DEDUP_ROOTS=()
    for _r in "${SCAN_ROOTS[@]}"; do
        _dup=0
        if [ ${#_DEDUP_ROOTS[@]} -gt 0 ]; then
            for _d in "${_DEDUP_ROOTS[@]}"; do [ "$_d" = "$_r" ] && { _dup=1; break; }; done
        fi
        [ "$_dup" = 0 ] && _DEDUP_ROOTS+=("$_r")
    done
    SCAN_ROOTS=("${_DEDUP_ROOTS[@]}")
fi

# Show resolved roots (audit-friendly), in the order they will be scanned.
if [ "$QUIET" = 0 ]; then
    info "Source-scan roots (scanned in this order; existing directories only):"
    for r in "${SCAN_ROOTS[@]}"; do
        [ -d "$r" ] && printf '         %s\n' "$r"
    done
fi

# Run the full per-file detection battery against ONE file. Shared by the
# working-tree walk (section 8) and the branch-blob scan (section 8b) so both
# apply byte-identical logic. All IO reads $1 (the on-disk path to scan); $2 is
# the DISPLAY string shown in hit messages (a real path for the working tree,
# "<repo>@{refs}:<path>" for a blob extracted from a branch). $3 is the byte
# size if the caller already knows it (recomputed when empty). The >5MB skip and
# progress tick stay in the caller; this function just runs the checks.
scan_one_path() {
    local rp="$1" disp="$2" size="${3:-}"
    case "$size" in ''|*[!0-9]*) size=$(stat -f '%z' "$rp" 2>/dev/null) ;; esac
    case "$size" in ''|*[!0-9]*) size=0 ;; esac
    local base="${rp##*/}"

    # ---------- T1: invariant string grep ----------
    if LC_ALL=C grep -E -l "$INVARIANT_RE" "$rp" >/dev/null 2>&1; then
        hit "PolinRider invariant in: $disp"
    fi

    # ---------- T1: SHA-256 file hash match ----------
    local file_hash
    file_hash=$(shasum -a 256 "$rp" 2>/dev/null | cut -d' ' -f1)
    if [ -n "$file_hash" ] \
       && printf '%s\n' "${KNOWN_BAD_HASHES[@]}" | grep -F -q -x "$file_hash"; then
        hit "File SHA-256 matches captured PolinRider sample: $disp  ($file_hash)"
    fi

    # ---------- T3: config size anomaly ----------
    case "$base" in
        d2.config.js|postcss.config.js|postcss.config.cjs|babel.config.js|babel.config.cjs|eslint.config.js|eslint.config.cjs)
            if [ "$size" -gt 8192 ]; then
                hit "Config size anomaly: $disp is $size bytes (expected < 8192)"
            fi
            ;;
    esac

    # ---------- T3: binary-asset masquerade (woff2/etc. without valid magic bytes) ----------
    case "$base" in
        *.woff2|*.woff|*.ttf|*.otf|*.eot|*.png|*.jpg|*.jpeg|*.gif|*.webp)
            local magic looks_real js_hits
            magic=$(head -c 8 "$rp" 2>/dev/null | xxd -p 2>/dev/null | head -c 16)
            looks_real=0
            case "$magic" in
                774f4632*|774f4646*|00010000*|4f54544f*|74727565*|74797031*) looks_real=1 ;;
                89504e47*|ffd8ff*|47494638*|424d*|52494646*) looks_real=1 ;;
            esac
            if [ "$looks_real" = 0 ]; then
                # Count DISTINCT JS keywords present (not matching lines): an
                # obfuscated payload is often a single minified line, which a
                # `grep -c` line count would score as 1 and miss. Read the head
                # once, then test each keyword against it.
                local head_sample kw
                # Strip NUL before capturing: command substitution drops NUL and
                # grep treats NUL-bearing input as binary (no match), so a payload
                # with binary framing before its JS body would score 0. Stripping
                # NUL keeps the JS keywords matchable; head closes the pipe at 8 KB.
                head_sample=$(LC_ALL=C tr -d '\000' < "$rp" 2>/dev/null | head -c 8192)
                js_hits=0
                for kw in 'function' 'require' 'module\.exports' 'global\[' '=>'; do
                    printf '%s' "$head_sample" | LC_ALL=C grep -E -q "$kw" && js_hits=$((js_hits + 1))
                done
                if [ "$js_hits" -ge 2 ]; then
                    hit "Binary-asset masquerade (JS in $base): $disp"
                fi
            fi
            ;;
    esac

    # ---------- T2/T3 consolidated awk pass ----------
    local awk_flags
    awk_flags=$(awk -f "$AWK_SCAN_SCRIPT" "$rp" 2>/dev/null)
    awk_flags="${awk_flags//$'\n'/ }"
    if [ -n "$awk_flags" ]; then
        case " $awk_flags " in *" SHUFFLE "*)         hit "T2 shuffle-cipher decoder structure: $disp" ;; esac
        case " $awk_flags " in *" DETACHED_SPAWN "*)  hit "T2 detached node -e spawn pattern: $disp" ;; esac
        case " $awk_flags " in *" DEAD_DROP "*)       hit "T2 blockchain dead-drop chain: $disp" ;; esac
        case " $awk_flags " in *" CTOR_EVAL "*)       hit "T2 indirect Function-constructor reach: $disp" ;; esac
        case " $awk_flags " in *" PAYLOAD_APPENDED "*) hit "T2 config with IIFE appended after blank-line padding: $disp" ;; esac
        # Only treat LONG as a finding for high-value config files
        case " $awk_flags " in
            *" LONG "*)
                case "$base" in
                    d2.config.js|*.config.js|*.config.mjs|*.config.cjs|*.config.ts|tasks.json|launch.json|settings.json)
                        check "Long dense line (>1500 chars); review for obfuscated/minified content (heuristic, not a confirmed hit): $disp"
                        ;;
                esac
                ;;
        esac
    fi

    # ---------- T3: VS Code tasks.json OR settings.json with runOn:folderOpen + executor ----------
    if { [ "$base" = "tasks.json" ] || [ "$base" = "settings.json" ]; } && [[ "$rp" == *"/.vscode/"* ]]; then
        if LC_ALL=C grep -E -q '"runOn"[[:space:]]*:[[:space:]]*"folderOpen"' "$rp" 2>/dev/null \
           && LC_ALL=C grep -E -q '"(command|args|shellCmd)"[[:space:]]*:' "$rp" 2>/dev/null \
           && LC_ALL=C grep -E -q '\b(curl|wget|node|npm|npx|yarn|pnpm|deno|bun|sh|bash|/bin/sh|/bin/bash|powershell|pwsh|cmd\.exe)\b' "$rp" 2>/dev/null; then
            hit ".vscode/$base with folderOpen + executor: $disp"
        fi
    fi

    # ---------- T3: VS Code settings.json enables automatic tasks (trust-prompt bypass) ----------
    if [ "$base" = "settings.json" ] && [[ "$rp" == *"/.vscode/"* ]]; then
        if LC_ALL=C grep -E -q '"task\.allowAutomaticTasks"[[:space:]]*:[[:space:]]*true' "$rp" 2>/dev/null; then
            hit ".vscode/settings.json sets task.allowAutomaticTasks:true (auto-run, trust-prompt bypass): $disp"
        fi
    fi

    # ---------- T2: node/deno/bun executes a binary-asset file (masquerade launch) ----------
    if LC_ALL=C grep -E -q "\\b(node|deno|bun)[[:space:]]+([Rr][Uu][Nn][[:space:]]+)?(-[^[:space:]]+[[:space:]]+)*[\"']?[A-Za-z0-9_./\\-]+\\.(woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp)\\b" "$rp" 2>/dev/null; then
        hit "node/deno/bun executes a binary-asset file (masquerade payload launch): $disp"
    fi
}

for root in "${SCAN_ROOTS[@]}"; do
    [ -d "$root" ] || continue
    [ "$QUIET" = 0 ] && printf '  scanning %s ...\n' "$root"

    # One combined find streams every candidate file directly into the scan
    # loop via process substitution. This means `progress_tick` fires the
    # moment each file is discovered — including during the big walk phase —
    # so the spinner stays alive on stderr from start to finish. `find` walks
    # each path exactly once, so no awk dedup is needed.
    #
    # Matches are EITHER:
    #   (a) a campaign-targeted filename anywhere (configs, App.js, *.woff2…)
    #   (b) any .js/.mjs/.cjs/.ts/.tsx/.jsx file under */src/, */lib/, or
    #       */app/ — the runtime-active injection sites (AppWrapper.js,
    #       locales/<lang>/index.js, top-level utils.js, etc.).
    while IFS= read -r f; do
        [ -z "$f" ] && continue
        progress_tick "$f"
        # Skip files larger than 5MB to avoid giant legitimate fonts/bundles.
        # Defensive: pin size to a clean non-negative integer first (stat can emit
        # nothing if the file vanished, or stray whitespace on some APFS configs).
        size=$(stat -f '%z' "$f" 2>/dev/null)
        case "$size" in ''|*[!0-9]*) size=0 ;; esac
        [ "$size" -gt 5242880 ] && continue

        scan_one_path "$f" "$f" "$size"

    done < <(find "$root" \
        \( -type d \( \
            -name node_modules -o -name bower_components -o -name vendor -o \
            -name .git -o -name .hg -o -name .svn -o \
            -name venv -o -name .venv -o -name env -o \
            -name __pycache__ -o -name .tox -o -name .pytest_cache -o -name .mypy_cache -o \
            -name dist -o -name build -o -name out -o -name .next -o -name .nuxt -o \
            -name target -o -name .gradle -o -name .cache -o \
            -name .idea -o -name .omc -o -name .claude \
        \) -prune \) -o \
        -type f \( \
            -name 'd2.config.js' -o \
            -name 'postcss.config.*' -o \
            -name 'tailwind.config.*' -o \
            -name 'eslint.config.*' -o \
            -name 'next.config.*' -o \
            -name 'vite.config.*' -o \
            -name 'webpack.config.*' -o \
            -name 'babel.config.*' -o \
            -name 'jest.config.*' -o \
            -name 'astro.config.*' -o \
            -name 'truffle.js' -o \
            -name 'gridsome.config.*' -o \
            -name 'vue.config.*' -o \
            -name 'temp_auto_push.bat' -o \
            -name 'temp_interactive_push.bat' -o \
            -name 'branch_structure.json' -o \
            -name 'App.js' -o \
            -name 'app.js' -o \
            -name 'AppWrapper.js' -o \
            -name 'main.js' -o \
            -name 'utils.js' -o \
            -name 'tasks.json' -o \
            -name 'launch.json' -o \
            -name 'settings.json' -o \
            -name 'LAST_COMMIT_DATE' -o \
            -name '*.woff2' -o \
            \( \
                \( -path '*/src/*' -o -path '*/lib/*' -o -path '*/app/*' \) \
                -a \
                \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \
                   -o -name '*.ts' -o -name '*.tsx' -o -name '*.jsx' \) \
            \) \
        \) -print 2>/dev/null)
done


# ===================================================================
# 8b. Git branch scan — invariant markers across ALL branch tips
# ===================================================================
# Section 8 only sees the CHECKED-OUT branch. The 2026-06-03 force-push vector
# parked the payload on NON-default branches: cache-cleaner-app's default HEAD
# was clean, yet 80 of its 94 branches carried the loader
# (ORG_SWEEP_FINDINGS_2026-06-03.md). This pass enumerates the union of objects
# across every branch tip (refs/heads + refs/remotes + refs/tags) of every git
# repo under the scan roots with `git rev-list`, then scans each UNIQUE blob
# once, so a dormant infected branch cannot hide.
#
# Read-only: `git rev-list` / `cat-file` read objects only — no checkout, no
# hooks, no fetch, no network, no mutation. Safe to run against an untrusted clone.
#
# Fail-fast ordering: the dhis2-org repos named in the org sweep are scanned
# FIRST. The remediation for ANY infected clone is identical (wipe + rotate),
# so surfacing the first hit as early as possible is the whole point.
section "Git branch scan (all branch tips)"

if ! command -v git >/dev/null 2>&1; then
    warn "git not found — skipping branch scan (working-tree scan above still ran)"
else
    # dhis2-org repos named in the 2026-06-03 org sweep. Matched by the clone
    # directory's basename ("they most likely have that name on the filesystem").
    # NOTE: app-platform and fhir-ig-generator-app were force-push TARGETS but
    # scanner-CLEAN in that sweep — kept here to RE-VERIFY; a hit on either now
    # means a NEW or rotated payload, not the original finding.
    KNOWN_INFECTED_REPOS=(
        "capture-app"
        "app-management-app"
        "aggregate-data-entry-app"
        "charts-app"
        "cache-cleaner-app"
        "app-hub"
        "approval-app"
        "action-semantic-release"
        "camel-hie-boot"
        "camel-dhis2"
        "camel-archetype-dhis2"
        "cancer-registry-app"
        "app-platform"
        "fhir-ig-generator-app"
    )

    # Known-bad git blob SHA-1s of the Vector-2 "woff2 kit" (ORG_SWEEP_HANDOFF.md
    # §2 / §4.3a). `git ls-tree` prints each blob's object SHA directly, so this is
    # content-exact with ZERO false positives and NO hashing — a real FontAwesome
    # woff2 or a normal .vscode config has a different blob SHA. Path-only matching
    # lies both ways (it burned the org sweep on app-platform); blob-SHA does not.
    KNOWN_BAD_BLOBS=(
        "b4aa3256fbe2cc63a43467f1b4df34d6f7867031"   # public/fonts/fa-solid-400.woff2
        "5e226620d2e360205cc8634e3c581a008d382561"   # .vscode/tasks.json
        "934d55548c36ff0e330f2a2ba69bf74b10a7dcba"   # .vscode/settings.json
    )

    # Campaign injection-site paths. Blobs at these paths get the FULL deep scan
    # on every branch (SHA-256 + masquerade + .vscode/config structural — catches
    # the loader configs AND rotated woff2 kits whose blob SHA we don't yet know).
    # KNOWN LIMITATION: broad source files (App.js, utils.js, any src/lib/app
    # *.js) are NOT deep-scanned per branch — extracting thousands of unique
    # source blobs across every ref would be prohibitively slow. On non-default
    # branches those files get the L0 invariant grep only, so a payload that
    # trips ONLY a Tier-2 STRUCTURAL rule (shuffle cipher, dead-drop, etc.) with
    # zero hard-IOC strings could be missed on a dormant branch. In practice the
    # loader always carries T1 marker strings (decoder alphabets, global['!']…),
    # which L0 catches; the working-tree scan still runs the full battery on the
    # checked-out copy of those files.
    CANDIDATE_PATH_RE='(^|/)(d2\.config\.js|[^/]+\.config\.(js|mjs|cjs|ts|cts|mts|json)|tasks\.json|launch\.json|settings\.json|truffle\.js|branch_structure\.json|temp_auto_push\.bat|temp_interactive_push\.bat|LAST_COMMIT_DATE)$|\.(woff2|woff|ttf|otf|eot|png|jpe?g|gif|webp)$'

    # Scan one git repo across ALL branch tips, deduping at the BLOB level so every
    # distinct blob is read exactly once regardless of how many refs carry it. Layers:
    #   L0  invariant markers in ANY blob's content (Vector-1; force-push source hits)
    #   L1  known-bad blob-SHA match (Vector-2 woff2 kit; content-exact, no read)
    #   L2  SHA-256 of unique candidate-path blobs vs the file-hash IOC list (loaders)
    #   L3  masquerade / .vscode-structural / awk-T2 on those blobs (rotated kits)
    #
    # The OLD design ran `git grep` + `ls-tree -r` once PER REF: O(distinct-tips x
    # tree-size) file ops that re-read every blob once per containing ref (~33x on
    # dhis2-core) and overflowed ARG_MAX on ~23k tip oids — a 12h hang. The NEW design
    # enumerates the union of objects ONCE via `git rev-list --no-walk --objects`
    # (tip trees only, natively deduped, tips fed via --stdin to dodge ARG_MAX), then
    # greps each UNIQUE blob once. Coverage is byte-identical to the per-ref ls-tree
    # union (proven on dhis2-core: 32,536 == 32,536 blobs on an 8-tip sample). Full
    # branch scan of dhis2-core (69k refs / 23k tips / 204k blobs): ~14s vs ~12h.
    #
    # Ref attribution is intentionally dropped (rev-list --objects does not tag which
    # ref an object came from, and reverse-mapping is expensive): a hit means "this
    # malicious blob exists on some branch tip of this repo" — fully actionable; the
    # exact branch list is forensic detail (`git branch -a --contains <oid>`).

    # CPU count for the parallel L0 grep (Darwin).
    NCPU=$(sysctl -n hw.ncpu 2>/dev/null)
    case "$NCPU" in ''|*[!0-9]*) NCPU=4 ;; esac
    [ "$NCPU" -lt 1 ] && NCPU=1

    # L0 needs a byte-exact reader for `git cat-file --batch`: it must frame each
    # object by its declared <size> header, NOT by lines (line splitting breaks on
    # binary blobs and on content that ends mid-line). Perl does this cleanly and
    # ships with every supported macOS. If perl is somehow absent we fall back to a
    # slow per-blob `cat-file | grep` (still correct, just unparallelised).
    PERL_OK=0
    command -v perl >/dev/null 2>&1 && PERL_OK=1
    L0_PERL_SCRIPT="$TMPDIR_HUNT/l0_grep.pl"
    if [ "$PERL_OK" = 1 ]; then
        cat > "$L0_PERL_SCRIPT" <<'PERL_EOF'
# Read `git cat-file --batch` from STDIN; print the oid of any BLOB whose content
# matches $ENV{IRE}. Each object is framed by its <size> header, so binary blobs and
# size boundaries are handled correctly. IRE has literal $ and @ pre-escaped by the
# caller (they are Perl regex metacharacters but literal inside the IOC strings).
use strict; use warnings;
my $rx = qr/$ENV{IRE}/;
my $cap = 16 * 1024 * 1024;   # IOC strings live in KB-sized loaders; don't slurp giant blobs
binmode STDIN;
my $buf = '';
while (1) {
    my $hdr = <STDIN>;
    last unless defined $hdr;
    $hdr =~ s/\r?\n$//;
    next if $hdr eq '';
    my ($oid, $type, $size) = split / /, $hdr;
    next if !defined $type || !defined $size || $type !~ /^(blob|commit|tree|tag)$/;
    if ($size > $cap) {               # oversized: drain the body to stay framed, skip the grep
        my ($left, $junk) = ($size, undef);
        while ($left > 0) { my $n = read(STDIN, $junk, $left > 65536 ? 65536 : $left); last if !$n; $left -= $n; }
        read(STDIN, my $nl2, 1);
        next;
    }
    $buf = '';
    my $got = 0;
    while ($got < $size) {
        my $r = read(STDIN, $buf, $size - $got, $got);
        last if !defined $r || $r == 0;
        $got += $r;
    }
    my $nl; read(STDIN, $nl, 1);     # consume the trailing newline cat-file appends
    next unless $type eq 'blob';     # only blobs are "files" (matches old git grep)
    print "$oid\n" if $buf =~ $rx;
}
PERL_EOF
    fi

    # Grep every UNIQUE blob's content for INVARIANT_RE once. $1=repo, $2=blob-oid
    # file (sorted/unique), $3=output file of matching oids. Determinism: results are
    # collected from all workers and sorted before use, never emitted in completion
    # order.
    branch_l0_grep() {
        local repo="$1" blob_oids="$2" out="$3"
        : > "$out"
        local nb; nb=$(wc -l < "$blob_oids" 2>/dev/null | tr -d ' ')
        [ "${nb:-0}" -eq 0 ] && return 0

        if [ "$PERL_OK" = 1 ]; then
            local per c
            per=$(( (nb + NCPU - 1) / NCPU )); [ "$per" -lt 1 ] && per=1
            rm -f "$TMPDIR_HUNT"/l0chunk_* 2>/dev/null
            split -l "$per" "$blob_oids" "$TMPDIR_HUNT/l0chunk_"
            for c in "$TMPDIR_HUNT"/l0chunk_*; do
                [ -f "$c" ] || continue
                git -C "$repo" cat-file --batch < "$c" \
                    | IRE="$INVARIANT_RE_PERL" perl "$L0_PERL_SCRIPT" > "$c.hits" 2>/dev/null &
            done
            wait
            cat "$TMPDIR_HUNT"/l0chunk_*.hits 2>/dev/null | sort -u > "$out"
            rm -f "$TMPDIR_HUNT"/l0chunk_* 2>/dev/null
        else
            # Slow fallback (no perl): one cat-file + grep per unique blob.
            local oid
            while IFS= read -r oid; do
                [ -n "$oid" ] || continue
                git -C "$repo" cat-file blob "$oid" 2>/dev/null \
                    | LC_ALL=C grep -E -q "$INVARIANT_RE" && printf '%s\n' "$oid"
            done < "$blob_oids" | sort -u > "$out"
        fi
    }

    git_branch_scan_repo() {
        local repo="$1"
        git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || return 0

        # 1. Unique tip oids (one per distinct objectname). sort -u replaces the old
        #    O(N^2) bash dedup; the tips go to rev-list via --stdin (NOT argv), which
        #    is what dodges the ARG_MAX trap on ~23k oids.
        local tips="$TMPDIR_HUNT/branch_tips.txt"
        git -C "$repo" for-each-ref --format='%(objectname)' \
            refs/heads refs/remotes refs/tags 2>/dev/null | sort -u > "$tips"
        [ -s "$tips" ] || return 0
        progress_tick "$repo ($(wc -l < "$tips" | tr -d ' ') tips)"

        # 2. Union of every object reachable from those tips, computed ONCE and
        #    natively deduped. --no-walk: tip trees only, no ancestor history (== the
        #    set the old per-ref ls-tree covered). --ignore-missing: a tip deleted /
        #    repacked mid-run (TOCTOU) is skipped, not fatal. Lines: "<oid>" (commit)
        #    or "<oid> <path>" (tree/blob).
        local revlist="$TMPDIR_HUNT/branch_revlist.txt"
        git -C "$repo" rev-list --no-walk --objects --ignore-missing --stdin \
            < "$tips" > "$revlist" 2>/dev/null

        # 3. Classify to the UNIQUE blob-oid set. cat-file --batch-check reads object
        #    headers only (type), so this is cheap (~2s for 800k objects).
        local blob_oids="$TMPDIR_HUNT/branch_blob_oids.txt"
        awk '{print $1}' "$revlist" \
            | git -C "$repo" cat-file --batch-check='%(objectname) %(objecttype)' 2>/dev/null \
            | awk '$2=="blob"{print $1}' | sort -u > "$blob_oids"
        [ -s "$blob_oids" ] || return 0

        # ---------- L0: invariant markers in ANY blob (content) ----------
        local matched="$TMPDIR_HUNT/branch_l0_matched.txt"
        branch_l0_grep "$repo" "$blob_oids" "$matched"
        if [ -s "$matched" ]; then
            # Map each matching oid to a representative path (its first-seen path in
            # the rev-list output); sort by path for deterministic output.
            local l0rep="$TMPDIR_HUNT/branch_l0_report.txt" n0 first0
            awk 'NR==FNR{m[$1]=1;next}
                 { sp=index($0," "); if(sp==0)next;
                   o=substr($0,1,sp-1); p=substr($0,sp+1);
                   if((o in m)&&!(o in seen)){seen[o]=1; print p "\t" o} }' \
                "$matched" "$revlist" | sort > "$l0rep"
            n0=$(wc -l < "$l0rep" | tr -d ' ')
            first0=$(head -1 "$l0rep" | cut -f1)
            hit "PolinRider invariant in $n0 branch-tip blob(s) of git repo: $repo  (e.g. $first0)"
            if [ "$QUIET" = 0 ]; then
                awk -F'\t' '{print $1"  ["substr($2,1,12)"]"}' "$l0rep" \
                    | head -20 | sed 's/^/           /'
                [ "$n0" -gt 20 ] && printf '           ... (showing first 20; %s blob(s) total)\n' "$n0"
            fi
        fi

        # ---------- L1: known-bad blob SHA (content-exact, no content read) ----------
        local kbb="$TMPDIR_HUNT/branch_kbb.txt" l1="$TMPDIR_HUNT/branch_l1.txt"
        printf '%s\n' "${KNOWN_BAD_BLOBS[@]}" | sort -u > "$kbb"
        awk 'NR==FNR{k[$1]=1;next} ($1 in k){print $1}' "$kbb" "$blob_oids" > "$l1"
        if [ -s "$l1" ]; then
            local l1rep
            l1rep=$(awk 'NR==FNR{m[$1]=1;next}
                         { sp=index($0," "); if(sp==0)next;
                           o=substr($0,1,sp-1); p=substr($0,sp+1);
                           if((o in m)&&!(o in seen)){seen[o]=1; print p} }' \
                        "$l1" "$revlist" | sort -u | tr '\n' ' ')
            hit "Known-bad woff2-kit blob (content-exact) in git repo: $repo  [${l1rep% }]"
        fi

        # ---------- L2/L3: structural/heuristic battery on candidate-path blobs ------
        # Candidate = a blob whose representative path matches CANDIDATE_PATH_RE, one
        # entry per unique oid. Extract each to a path-preserving temp file (so the
        # basename and /.vscode/ logic inside scan_one_path apply) and run the full
        # per-file battery once. L1 blobs are skipped (already reported).
        local cand="$TMPDIR_HUNT/branch_cand.txt"
        CRE="$CANDIDATE_PATH_RE" awk '
            BEGIN{cre=ENVIRON["CRE"]}
            { sp=index($0," "); if(sp==0)next;
              o=substr($0,1,sp-1); p=substr($0,sp+1);
              if(p ~ cre && !(o in seen)){seen[o]=1; print p "\t" o} }' \
            "$revlist" | sort > "$cand"
        if [ -s "$cand" ]; then
            local path obj csize tmpf is_l1 b line
            # Split on the LAST tab: the oid is the fixed-length final field and never
            # contains a tab, but a (hostile) git path legally can — IFS=$'\t' read
            # would mis-split it and silently drop the candidate from the deep scan.
            while IFS= read -r line; do
                obj=${line##*$'\t'}
                path=${line%$'\t'*}
                [ -n "$obj" ] || continue
                # SECURITY: the path comes from a (possibly hostile) repo's git tree.
                # A '..'-named or absolute tree entry would make the redirect below
                # WRITE — and the later rm -f DELETE — a file OUTSIDE blobx/ (e.g.
                # ~/.ssh/..., crontab). Reject traversal / absolute / empty-component
                # paths before building tmpf, preserving the read-only invariant.
                case "$path" in
                    /* | .. | ../* | */../* | */.. | *//* ) continue ;;
                esac
                is_l1=0
                for b in "${KNOWN_BAD_BLOBS[@]}"; do [ "$b" = "$obj" ] && { is_l1=1; break; }; done
                [ "$is_l1" = 1 ] && continue
                csize=$(git -C "$repo" cat-file -s "$obj" 2>/dev/null)
                case "$csize" in ''|*[!0-9]*) csize=0 ;; esac
                [ "$csize" -gt 5242880 ] && continue
                tmpf="$TMPDIR_HUNT/blobx/$path"
                mkdir -p "$(dirname "$tmpf")" 2>/dev/null
                if git -C "$repo" cat-file blob "$obj" > "$tmpf" 2>/dev/null; then
                    progress_tick "$repo:$path"
                    scan_one_path "$tmpf" "$repo:$path (branch-tip blob ${obj:0:12})" "$csize"
                    rm -f "$tmpf"
                fi
            done < "$cand"
        fi
    }

    # Discover every git repo under the scan roots in one pass. A repo root is the
    # parent of a `.git` entry (dir for normal clones, file for submodules /
    # worktrees). node_modules is pruned (vendored deps ship their own .git that
    # we don't care about); the .git entry itself is pruned so we don't descend
    # into the object store looking for more.
    GIT_REPOS_ALL="$TMPDIR_HUNT/git_repos_all.txt"
    : > "$GIT_REPOS_ALL"
    for root in "${SCAN_ROOTS[@]}"; do
        [ -d "$root" ] || continue
        find "$root" -path '*/node_modules' -prune -o -name .git -prune -print 2>/dev/null
    done | sed 's#/\.git$##' | sort -u > "$GIT_REPOS_ALL"

    # Partition into campaign-targeted (basename matches the list) and the rest.
    GIT_REPOS_KNOWN="$TMPDIR_HUNT/git_repos_known.txt"
    GIT_REPOS_OTHER="$TMPDIR_HUNT/git_repos_other.txt"
    : > "$GIT_REPOS_KNOWN"
    : > "$GIT_REPOS_OTHER"
    while IFS= read -r repo; do
        [ -z "$repo" ] && continue
        rbase=$(printf '%s' "${repo##*/}" | tr '[:upper:]' '[:lower:]')
        is_known=0
        for name in "${KNOWN_INFECTED_REPOS[@]}"; do
            [ "$rbase" = "$name" ] && { is_known=1; break; }
        done
        if [ "$is_known" = 1 ]; then
            printf '%s\n' "$repo" >> "$GIT_REPOS_KNOWN"
        else
            printf '%s\n' "$repo" >> "$GIT_REPOS_OTHER"
        fi
    done < "$GIT_REPOS_ALL"

    known_n=$(wc -l < "$GIT_REPOS_KNOWN" 2>/dev/null | tr -d ' ')
    other_n=$(wc -l < "$GIT_REPOS_OTHER" 2>/dev/null | tr -d ' ')
    known_n=${known_n:-0}
    other_n=${other_n:-0}
    branch_findings_start=$FINDINGS

    if [ "$((known_n + other_n))" -eq 0 ]; then
        ok "No git repositories found under the scan roots"
    else
        info "Git repos found: $known_n campaign-targeted, $other_n other"

        # Phase A — campaign-targeted repos FIRST (fail-fast).
        if [ "$known_n" -gt 0 ]; then
            [ "$QUIET" = 0 ] && printf '  scanning campaign-targeted repos first ...\n'
            while IFS= read -r repo; do
                [ -n "$repo" ] && git_branch_scan_repo "$repo"
            done < "$GIT_REPOS_KNOWN"
        fi

        # Phase B — every other local git repo.
        if [ "$other_n" -gt 0 ]; then
            [ "$QUIET" = 0 ] && printf '  scanning remaining git repos ...\n'
            while IFS= read -r repo; do
                [ -n "$repo" ] && git_branch_scan_repo "$repo"
            done < "$GIT_REPOS_OTHER"
        fi
        progress_clear

        [ "$FINDINGS" -eq "$branch_findings_start" ] \
            && ok "No PolinRider invariants on any branch tip ($((known_n + other_n)) repo(s) scanned)"
    fi
fi


# ===================================================================
# 9. Malicious npm packages in package.json files
# ===================================================================
section "Known-malicious npm dependencies"

BAD_NPM=(
    "tailwindcss-style-animate"
    "tailwind-mainanimation"
    "tailwind-autoanimation"
    "tailwind-animationbased"
    "tailwindcss-typography-style"
    "tailwindcss-style-modify"
    "tailwindcss-animate-style"
)
NPM_RE=$(IFS='|'; printf '"(%s)"' "${BAD_NPM[*]}")

for root in "${SCAN_ROOTS[@]}"; do
    [ -d "$root" ] || continue
    # Lockfiles capture every transitive dep; package.json alone misses anything
    # pulled in indirectly. bun.lockb is binary — grep handles it but may print
    # "Binary file ... matches" which we redirect away. yarn.lock and
    # pnpm-lock.yaml format the dep names slightly differently from
    # package.json's "name": "value" but the regex still catches the unique
    # substring `tailwindcss-style-animate` etc.
    pkgs=$(find "$root" \
        \( -type d \( \
            -name node_modules -o -name bower_components -o -name vendor -o \
            -name .git -o -name .hg -o -name .svn -o \
            -name venv -o -name .venv -o -name env -o \
            -name __pycache__ -o -name .tox -o -name .pytest_cache -o -name .mypy_cache -o \
            -name dist -o -name build -o -name out -o -name .next -o -name .nuxt -o \
            -name target -o -name .gradle -o -name .cache -o \
            -name .idea -o -name .omc -o -name .claude \
        \) -prune \) -o \
        -type f \( \
            -name 'package.json' -o \
            -name 'package-lock.json' -o \
            -name 'pnpm-lock.yaml' -o \
            -name 'yarn.lock' -o \
            -name 'bun.lock' -o \
            -name 'bun.lockb' \
        \) -print 2>/dev/null)
    while IFS= read -r f; do
        [ -z "$f" ] && continue
        progress_tick "$f"
        if LC_ALL=C grep -E "$NPM_RE" "$f" >/dev/null 2>&1; then
            hit "Malicious npm dependency in: $f"
        fi
    done <<< "$pkgs"
done
progress_clear

# ===================================================================
# 10. Unified log query (last 30 days, suspicious patterns)
# ===================================================================
if [ "$FULL" = 1 ] && [ -z "$FOLDER" ]; then
    section "Unified log search (last 30 days, slow)"

    # Use absolute path /usr/bin/log to bypass any zsh/bash `log` builtin or alias.
    # `command -v log` would also match a shell builtin, so test the binary directly.
    if [ -x /usr/bin/log ]; then
        if [ "$(id -u)" -eq 0 ]; then
            info "Searching unified log for trongrid/aptoslabs/bsc-dataseed references..."
            # Three non-obvious things in this query:
            #   1. --style syslog: more compact than the default style. (Both styles emit
            #      a column-header line even on zero matches, so we still need step 3.)
            #   2. AND subsystem != "com.apple.log": /usr/bin/log writes a "log run
            #      noninteractively, ... args: ..." entry for every invocation. Our argv
            #      contains the IOC strings, so without this filter the query self-matches
            #      its own invocation, producing a permanent false positive on any
            #      sudoed run within the last 30 days.
            #   3. Post-grep for the IOC strings: guarantees only lines that actually
            #      contain a matched IOC make it into the output file. The column-header
            #      row that `log show` always prints does not contain the IOC strings, so
            #      it is filtered out — and [ -s file ] below now genuinely reflects
            #      "real matches present" vs. "header only / nothing".
            /usr/bin/log show --last 30d --style syslog \
                --predicate '(eventMessage CONTAINS "trongrid" OR eventMessage CONTAINS "aptoslabs" OR eventMessage CONTAINS "bsc-dataseed") AND subsystem != "com.apple.log"' \
                2>/dev/null \
                | grep -E "trongrid|aptoslabs|bsc-dataseed" \
                | head -20 > "$TMPDIR_HUNT/log_hits.txt"
            if [ -s "$TMPDIR_HUNT/log_hits.txt" ]; then
                check "Unified log contains references to blockchain C2 endpoints (last 30 days)"
                [ "$QUIET" = 0 ] && head -10 "$TMPDIR_HUNT/log_hits.txt" | sed 's/^/         /'
            else
                ok "No blockchain C2 references in last 30 days of unified log"
            fi
        else
            warn "Re-run with sudo for unified log search"
        fi
    fi
fi

# ===================================================================
# Summary
# ===================================================================
echo
echo "=========================================="
[ "$QUIET" = 0 ] && [ -z "$FOLDER" ] && {
    echo "Companion checks (this script is campaign-specific — these are not):"
    echo "  KnockKnock — persistent-item enumerator + code-signing audit"
    echo "               https://objective-see.org/products/knockknock.html"
    echo "  LuLu       — outbound-network firewall for ongoing monitoring"
    echo "               https://objective-see.org/products/lulu.html"
    echo "  Run KnockKnock before AND after this script — diff the output."
    echo
}
if [ "$FINDINGS" -eq 0 ] && [ "$CHECKS" -eq 0 ]; then
    if [ -n "$FOLDER" ]; then
        echo "RESULT: No PolinRider/Contagious Interview indicators found in $FOLDER"
    else
        echo "RESULT: No PolinRider/Contagious Interview indicators found."
        echo "        (Absence of indicators != absence of compromise. If this"
        echo "         host ran the malicious build, treat the credential exfil"
        echo "         as a given; persistence may simply not have been installed.)"
    fi
    exit 0
elif [ "$FINDINGS" -eq 0 ]; then
    # No malicious files; only [ CHECK ] advisories (host triage and/or weak file heuristics).
    echo "RESULT: No malicious files found. $CHECKS item(s) marked [ CHECK ] to review."
    echo "        [ CHECK ] lines are advisories (host observations or weak file"
    echo "        heuristics), NOT confirmed malware; many are benign on a normal dev Mac."
    echo "        If a [ CHECK ] looks real or unexpected, contact security@dhis2.org."
    exit 0
elif [ -n "$FOLDER" ]; then
    echo "RESULT: $FINDINGS malicious-file signature(s) found in $FOLDER — see each [ HIT ] above."
    echo
    echo "Recommended next steps:"
    echo "  1. Do NOT kill processes or delete files yet — capture forensics first."
    echo "  2. Run with --host (without -folder) for system-wide triage."
    echo "  3. Rotate every credential that was reachable from this directory."
    echo
    echo "Coordinate with: security@dhis2.org"
    exit 1
else
    echo "RESULT: $FINDINGS malicious-file signature(s) found — see each [ HIT ] above."
    [ "$CHECKS" -gt 0 ] && echo "        (Plus $CHECKS [ CHECK ] advisory item(s) to review.)"
    echo
    echo "Recommended next steps:"
    echo "  1. Do NOT kill processes or delete files yet — capture forensics first."
    echo "  2. Image the disk if you have the capability."
    echo "  3. Capture: ps -axo pid,ppid,user,etime,command > ps_$(date +%s).txt"
    echo "  4. Capture: lsof -nP -iTCP > sockets_$(date +%s).txt"
    echo "  5. Capture: log collect --output incident.logarchive (sudo)"
    echo "  6. For LaunchAgents found, copy the plist AND the binary it points at"
    echo "     before unloading: cp <plist> ~/forensics/  ; same for the target."
    echo "  7. Then unload: launchctl unload <plist> && rm <plist>"
    echo "  8. Rotate every credential that was reachable: browser-saved passwords,"
    echo "     SSH keys, .env contents, GitHub PATs, npm tokens, AWS/GCP/Azure keys,"
    echo "     Slack/Google sessions (logout-everywhere), DHIS2 creds."
    echo "  9. If suspicion is high, wipe and reinstall macOS. The actor's"
    echo "     persistence techniques include components that are signed with"
    echo "     stolen Apple Developer IDs and bypass XProtect."
    echo
    echo "Coordinate with: security@dhis2.org"
    exit 1
fi
