#!/bin/bash
# polinrider_hunt_linux.sh
# Linux triage scanner for PolinRider / Void Dokkaebi / Contagious Interview indicators.
# Author: Morten Svanæs (netroms@gmail.com)
#
# 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  579437732ae6da7d7b4f3ab4235905b4b00c83778ad8cce762dfebbfa895faa6
#   1.0.1    2026-06-05  a00a70470ff4e211861d937171ae2a757885d734c2349ab173ae41ad53362174
#   1.0.2    2026-06-05  3ca24d13f8f7e9ef43f0861b803358d34ac4232fd56ced98ce353cf28ab681a9
#   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:  sha256sum -c SHA256SUMS     (or: shasum -a 256 -c SHA256SUMS)
#   check sig:   gpg --verify SHA256SUMS.asc SHA256SUMS
#
# Stage 1 of the campaign is OS-agnostic Node.js; Stage 2/3 branch per platform.
# This script covers the Linux-side persistence, drop, and network indicators
# documented in POLINRIDER_INCIDENT_HANDOFF.md §2 and EVIDENCES.md.
#
# Mirrors polinrider_hunt_macos.sh section-for-section so output is identical
# in shape (same banner, same [ HIT ] / [ OK ] / [WARN ] / [INFO ] helpers,
# same exit codes, same flag semantics).
#
# By DEFAULT this scans your code only (cloned repos / dev folders) and reports
# malicious FILES as [ HIT ]. Host/system triage (systemd units, 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_linux.sh --scan-root <path> [--scan-root <path>]... [--host] [--full] [--quiet]
#        bash polinrider_hunt_linux.sh -folder <path> [--full] [--quiet]
#   --host              Also run host/system triage (off by default). Its findings
#                       print as [ CHECK ], never [ HIT ].
#   --full              Implies --host and adds slow checks (journalctl; needs root)
#   --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 network/cloud homes).
#   -folder <path>      Single-folder mode: scan ONLY that directory and skip
#                       all host-triage sections (systemd units, processes,
#                       network, browser staging, etc.). Fully non-interactive.
#
# 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); use --scan-root to set it there.
#
# Exit codes: 0 = clean, 1 = findings, 2 = error
#
# DESIGNED TO BE READ-ONLY. Does not modify, quarantine, or kill anything.
# Containment is a separate decision.
#
# Run as the affected user first, then optionally re-run with sudo for
# system-wide systemd units and root crontabs. Both passes are useful.
#
# COMPANION TOOLS (recommended — this script is campaign-specific):
#
#   rkhunter / chkrootkit — generic Linux rootkit detectors.
#                Run before AND after this script for a baseline diff.
#                apt install rkhunter chkrootkit / dnf / pacman.
#
#   Lynis      — system hardening + integrity audit. Surfaces persistence
#                vectors this script does not know about.
#                https://cisofy.com/lynis/
#
#   auditd     — kernel audit subsystem. If installed, `ausearch -k <key>`
#                may already have a record of suspicious exec()s.

# Re-exec under bash if started through another shell (e.g. `zsh script.sh`).
# 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)
            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 /root (or wherever root's home is),
# which is almost never the developer's tree. Resolve back to the invoking
# user's home so every $HOME-scoped check (systemd user units, drop dirs,
# browser staging, source scan) runs against the actual developer's tree.
# Without this, `sudo ./polinrider_hunt_linux.sh` would silently miss
# everything in $HOME and falsely report "clean".
SCAN_USER="$(whoami)"
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
    _user_home=$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6)
    [ -z "$_user_home" ] && _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
# 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.
AWK_SCAN_SCRIPT="$TMPDIR_HUNT/scan_one.awk"
cat > "$AWK_SCAN_SCRIPT" <<'AWK_EOF'
BEGIN {
    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:]]*\\)"
    spawn_pat    = "spawn[[:space:]]*\\([[:space:]]*[\"']node[\"'][[:space:]]*,[[:space:]]*\\[[[:space:]]*[\"']-e[\"']"
    detached_pat = "detached[[:space:]]*:[[:space:]]*true"
    stdio_pat    = "stdio[[:space:]]*:[[:space:]]*[\"']ignore[\"']"
    tron_pat = "(api\\.trongrid\\.io|fullnode\\.mainnet\\.aptoslabs\\.com)"
    bsc_pat  = "bsc-(dataseed|rpc)"
    eth_pat  = "eth_getTransactionByHash"
    ctor_pat = "[\"']\\.constructor\\.constructor|\\[[[:space:]]*[\"']constructor[\"'][[:space:]]*\\][[:space:]]*\\[[[:space:]]*[\"']constructor[\"']"
    LONG = 1500
    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
    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

# Self-PID — used to filter the script's own argv out of process scans
# (EVIDENCES.md §5.1 — the macOS unified-log query learned this the hard way).
SELF_PID=$$

# ---------- 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"
    if [ ${#f} -gt 60 ]; then
        f="...${f: -57}"
    fi
    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)" != "Linux" ]; then
    echo "ERROR: this script targets Linux." >&2
    exit 2
fi

# Distro detection — best-effort, never fatal
DISTRO="unknown"
if [ -r /etc/os-release ]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    DISTRO="${PRETTY_NAME:-${NAME:-unknown}}"
fi

# Init system — systemd is dominant since ~2015 but not universal
INIT_SYSTEM="unknown"
if [ -d /run/systemd/system ]; then
    INIT_SYSTEM="systemd"
elif [ -r /sbin/openrc-run ] || [ -d /etc/runlevels ]; then
    INIT_SYSTEM="openrc"
elif [ -d /etc/sv ]; then
    INIT_SYSTEM="runit"
elif [ -d /etc/init.d ]; then
    INIT_SYSTEM="sysvinit"
fi

[ "$QUIET" = 0 ] && {
    if [ -n "$FOLDER" ]; then
        echo "PolinRider / Contagious Interview Linux hunt v$HUNT_VERSION (single-folder mode)"
    else
        echo "PolinRider / Contagious Interview Linux 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 "Kernel: $(uname -r)   Arch: $(uname -m)   Init: $INIT_SYSTEM"
    echo "Distro: $DISTRO"
    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 /etc and /root checks)"
    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/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 journalctl search describe the MACHINE,
# not your code, and were causing confusion (lots of [ HIT ]-looking noise on
# normal hosts). 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 persistence labels (systemd user units, crontab markers,
#    autostart .desktop files)
# ===================================================================
section "Known-bad systemd units / autostart entries / crontab markers"

# systemd user-unit directories — checked regardless of init system because
# the unit files can be planted anyway; they just won't fire under non-systemd.
SYSTEMD_DIRS=(
    "$HOME/.config/systemd/user"
    "/etc/systemd/user"
    "/etc/systemd/system"
    "/usr/lib/systemd/user"
)

# Known label patterns. These mirror the macOS LaunchAgent labels — the actor
# reuses naming conventions across platforms (com.driver*, com.zoom*, etc.).
# Linux convention would normally use lowercase service names without a
# reverse-DNS prefix, so a com.* unit on Linux is suspicious on its own.
KNOWN_BAD_UNIT_PATTERNS=(
    "com.driver*.service"
    "com.drive.service"
    "com.drive.timer"
    "com.zoom*.service"
    "com.camdrive*.service"
    "com.avatar.update.wake.service"
    "com.apple.*.service"
)

shopt -s nullglob 2>/dev/null
for d in "${SYSTEMD_DIRS[@]}"; do
    [ -r "$d" ] || continue
    for pattern in "${KNOWN_BAD_UNIT_PATTERNS[@]}"; do
        for f in "$d"/$pattern; do
            [ -e "$f" ] && check "Known-bad unit name: $f"
        done
    done
done
shopt -u nullglob 2>/dev/null

# Autostart .desktop entries — the GUI-Linux equivalent of LaunchAgents
AUTOSTART_DIRS=(
    "$HOME/.config/autostart"
    "/etc/xdg/autostart"
)
for d in "${AUTOSTART_DIRS[@]}"; do
    [ -r "$d" ] || continue
    while IFS= read -r f; do
        # .desktop entries whose Exec= invokes node/python on a path under
        # the campaign's known drop locations are unambiguous hits.
        if grep -E '^Exec=.*(\.n2/|\.npl|CDrivers|WebCam|/tmp/.*\.(sh|js|py))' "$f" >/dev/null 2>&1; then
            check "Suspicious autostart entry: $f"
        fi
    done < <(find "$d" -maxdepth 1 -type f -name '*.desktop' 2>/dev/null)
done

# User crontab — the actor uses crontab as a parallel persistence channel.
# `crontab -l` against the invoking user; if root, also iterate system crontabs.
SUSPICIOUS_CRON_RE='\.n2/|\.npl|CDrivers|WebCam|/tmp/[^[:space:]]*\.(sh|js|py)|node -e|curl[[:space:]]+.*\|[[:space:]]*(sh|bash)|wget[[:space:]]+.*\|[[:space:]]*(sh|bash)'

# When sudo'd, run crontab -l as the original user so we see THEIR cron,
# not root's. Same logic as the $HOME resolution above.
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then
    user_cron=$(sudo -u "$SUDO_USER" crontab -l 2>/dev/null || true)
else
    user_cron=$(crontab -l 2>/dev/null || true)
fi
if [ -n "$user_cron" ]; then
    if printf '%s\n' "$user_cron" | grep -E "$SUSPICIOUS_CRON_RE" >/dev/null; then
        check "User crontab contains suspicious entries — review: crontab -l"
    fi
fi

# System cron dirs (root-only readable in part)
SYSTEM_CRON_DIRS=(
    /etc/cron.d
    /etc/cron.hourly
    /etc/cron.daily
    /etc/cron.weekly
    /etc/cron.monthly
    /var/spool/cron/crontabs
    /var/spool/cron
)
for d in "${SYSTEM_CRON_DIRS[@]}"; do
    [ -r "$d" ] || continue
    while IFS= read -r f; do
        if grep -E "$SUSPICIOUS_CRON_RE" "$f" >/dev/null 2>&1; then
            check "Suspicious cron entry in: $f"
        fi
    done < <(find "$d" -maxdepth 2 -type f 2>/dev/null)
done

# Shell rc files — quietest persistence channel. The campaign has not
# (publicly) been seen using these but checking is cheap.
for rc in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc"; do
    [ -f "$rc" ] || continue
    if grep -E '(\.n2/|\.npl|/var/tmp/CDrivers|/var/tmp/WebCam|node[[:space:]]+-e[[:space:]]+["'\''])' "$rc" >/dev/null 2>&1; then
        check "Suspicious entry in shell rc: $rc"
    fi
done

[ $CHECKS -eq 0 ] && ok "No known-bad systemd units, autostart, or cron entries found"

if [ "$INIT_SYSTEM" != "systemd" ] && [ "$INIT_SYSTEM" != "unknown" ]; then
    warn "Init system is $INIT_SYSTEM, not systemd — Section 1/2 systemd checks are not authoritative on this host"
fi

# ===================================================================
# 2. systemd units / autostart whose ExecStart points to suspicious paths
# ===================================================================
section "systemd / autostart ExecStart pointing at suspicious paths"

# Drop locations seen across reporting, adapted to Linux. /tmp matches only
# EXECUTABLE-looking targets, same discipline as the macOS script:
SUSPICIOUS_PATH_RE='/var/tmp/CDrivers|/var/tmp/WebCam|/var/tmp/[^[:space:]]*\.sh|(^|[^a-z])/tmp/[^[:space:]<>"]*\.(sh|py|pl|js|bash)|/\.n2/|/\.npl|/\.config/\.n2'

scan_unit_dir() {
    local dir="$1"
    [ -r "$dir" ] || return 0
    while IFS= read -r unit; do
        # ExecStart / ExecStartPre / ExecStartPost — all candidates
        if grep -E '^Exec(Start|StartPre|StartPost|StopPost)?=' "$unit" 2>/dev/null \
            | grep -E "$SUSPICIOUS_PATH_RE" >/dev/null; then
            local match
            match=$(grep -E '^Exec(Start|StartPre|StartPost|StopPost)?=' "$unit" 2>/dev/null \
                | grep -oE '(/var/tmp/[^[:space:]]+|/tmp/[^[:space:]]+|[^[:space:]]*\.n2[^[:space:]]*|[^[:space:]]*\.npl[^[:space:]]*)' \
                | head -3 | tr '\n' ' ')
            check "Unit references suspicious path: $unit  ->  $match"
        fi
        # curl|sh / wget|bash patterns inside a unit
        if grep -E '(curl|wget).*\|[[:space:]]*(sh|bash)' "$unit" >/dev/null 2>&1; then
            check "Unit contains curl|sh / wget|sh pattern: $unit"
        fi
    done < <(find "$dir" -maxdepth 3 -type f \( -name '*.service' -o -name '*.timer' -o -name '*.path' \) 2>/dev/null)
}

for d in "${SYSTEMD_DIRS[@]}"; do
    scan_unit_dir "$d"
done

# Autostart desktop entries — same Exec= scan
for d in "${AUTOSTART_DIRS[@]}"; do
    [ -r "$d" ] || continue
    while IFS= read -r f; do
        if grep -E '^Exec=' "$f" 2>/dev/null | grep -E "$SUSPICIOUS_PATH_RE" >/dev/null; then
            local_match=$(grep -E '^Exec=' "$f" 2>/dev/null | head -1)
            check "Autostart Exec= references suspicious path: $f  ->  $local_match"
        fi
    done < <(find "$d" -maxdepth 1 -type f -name '*.desktop' 2>/dev/null)
done

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

DROP_PATHS=(
    "/var/tmp/CDrivers"
    "/var/tmp/WebCam"
    "/tmp/.n2"
    "$HOME/.n2"
    "$HOME/.npl"
    "$HOME/.config/.n2"
    "$HOME/.pyp"
    "$HOME/.local/share/.n2"
)
for d in "${DROP_PATHS[@]}"; do
    if [ -e "$d" ]; then
        mtime=$(stat -c '%y' "$d" 2>/dev/null || echo '?')
        check "Drop location exists: $d (mtime: $mtime)"
        [ -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
        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

# /tmp subdirs containing .js or .py files recently modified are suspicious
# (legitimate /tmp usage almost never leaves persistent JS/Python around)
if [ -d /tmp ]; then
    while IFS= read -r f; do
        [ -z "$f" ] && continue
        # Skip the script's own tmp dir
        case "$f" in "$TMPDIR_HUNT"|"$TMPDIR_HUNT"/*) continue ;; esac
        check "Recent JS/Python file in /tmp: $f"
    done < <(find /tmp -maxdepth 3 -type f \( -name '*.js' -o -name '*.py' \) -mtime -30 2>/dev/null | head -20)
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 init/systemd after detach).
ps_out=$(ps -eo pid,ppid,user,etime,command 2>/dev/null)

# Filter out our own PID so the script never matches itself
ps_filtered=$(printf '%s\n' "$ps_out" | awk -v self="$SELF_PID" '$1 != self')

# node -e <something> processes — bracket trick to avoid matching the awk
node_e_procs=$(printf '%s\n' "$ps_filtered" | awk '/[n]ode[[:space:]]+-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_filtered" | 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_filtered" | 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"

# Known C2 IPs from POLINRIDER_INCIDENT_HANDOFF.md §2 (campaign-wide)
KNOWN_C2_IPS_RE='23\.227\.202\.244|23\.227\.203\.99|45\.61\.128\.61|144\.172\.105\.235|135\.181\.123\.177|95\.164\.17\.24|45\.140\.147\.208'

# BeaverTail / InvisibleFerret signature ports: 1224, 1244
have_net_tool=0
if command -v ss >/dev/null 2>&1; then
    have_net_tool=1
    sig_port=$(ss -tnpa state established 2>/dev/null | awk '/:1224|:1244/')
    if [ -n "$sig_port" ]; then
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            check "Established connection on signature port (1224/1244): $line"
        done <<< "$sig_port"
    else
        ok "No active connections on TCP 1224/1244"
    fi

    c2_hits=$(ss -tnpa state established 2>/dev/null | grep -E "$KNOWN_C2_IPS_RE" || true)
    if [ -n "$c2_hits" ]; then
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            check "Established connection to known C2 IP: $line"
        done <<< "$c2_hits"
    fi

    node_net=$(ss -tnpa 2>/dev/null | grep -E 'users:\(\("node"' || true)
    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
elif command -v netstat >/dev/null 2>&1; then
    have_net_tool=1
    sig_port=$(netstat -tnp 2>/dev/null | awk '/ESTABLISHED/' | awk '/:1224|:1244/' || true)
    if [ -n "$sig_port" ]; then
        while IFS= read -r line; do
            [ -z "$line" ] && continue
            check "Established connection on signature port (1224/1244): $line"
        done <<< "$sig_port"
    else
        ok "No active connections on TCP 1224/1244 (netstat fallback)"
    fi
fi
[ "$have_net_tool" -eq 0 ] && warn "Neither ss nor netstat available — skipping connection check"

# /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"

# Linux Chrome/Chromium/Brave/Edge/Vivaldi 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 'logins.json*' -o -name 'key4.db*' \) \
        -print 2>/dev/null)
fi
if [ -n "$suspicious_copies" ]; then
    while IFS= read -r f; do
        [ -z "$f" ] && continue
        check "Browser-credential file in unusual location: $f"
    done <<< "$suspicious_copies"
else
    ok "No copied browser credential files in suspicious locations"
fi

# Mtime/atime check on real browser data — recent access from outside the
# browser itself is suggestive but not conclusive (browsers touch these
# constantly). Only flag if atime is very recent AND no chrome/firefox
# process is currently running.
BROWSER_PROFILE_DIRS=(
    "$HOME/.config/google-chrome/Default"
    "$HOME/.config/google-chrome/Profile 1"
    "$HOME/.config/chromium/Default"
    "$HOME/.config/BraveSoftware/Brave-Browser/Default"
    "$HOME/.config/microsoft-edge/Default"
    "$HOME/.config/vivaldi/Default"
)
# Firefox profile glob
for ff in "$HOME"/.mozilla/firefox/*.default* "$HOME"/.mozilla/firefox/*.default-release; do
    [ -d "$ff" ] && BROWSER_PROFILE_DIRS+=("$ff")
done

for p in "${BROWSER_PROFILE_DIRS[@]}"; do
    [ -d "$p" ] || continue
    [ "$QUIET" = 0 ] && info "Found browser profile: $p"
done

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

persistence_dirs=()
for d in "${SYSTEMD_DIRS[@]}" "${AUTOSTART_DIRS[@]}"; do
    [ -r "$d" ] && persistence_dirs+=("$d")
done

if [ ${#persistence_dirs[@]} -gt 0 ]; then
    recent=$(find "${persistence_dirs[@]}" -type f \( -name '*.service' -o -name '*.timer' -o -name '*.path' -o -name '*.desktop' \) -mtime -90 2>/dev/null)
    if [ -n "$recent" ]; then
        info "Recently modified persistence files (review each — not all are malicious):"
        while IFS= read -r f; do
            [ -z "$f" ] && continue
            printf '         %s  %s\n' "$(stat -c '%y' "$f" 2>/dev/null)" "$f"
        done <<< "$recent"
    else
        ok "No persistence files modified in last 90 days"
    fi
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 (systemd units, 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"

# Same invariants as the macOS script — content-level markers, OS-agnostic.
# 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
    '_$_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
    "global\\['!'\\]"
    "global\\['_V'\\]"
    # T1 throttle markers
    "global\\['_p_t'\\]"
    "global\\['_t_t'\\]"
    "global\\['_t_c'\\]"
    "global\\['_t_0'\\]"
    # T1 TRON wallets
    "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP"
    "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG"
    # T1 Aptos transaction hashes
    "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e"
    "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3"
    # T1 XOR keys (regex-escaped)
    "2\\[gWfGj;<:-93Z\\^C"
    "m6:tTh\\^D\\)cBz\\?NM\\]"
    # T2 blockchain endpoints
    "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
    "flo-ct-flo360"
)
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 confirmed PolinRider loader files (T1, unambiguous).
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"
)


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 synced trees (cloud mounts, network homes) and was
    # a 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 network/cloud homes 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>:<path> (on N ref(s))" 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 -c '%s' "$rp" 2>/dev/null || echo 0) ;; 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=$(sha256sum "$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 ----------
    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
        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. `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.
        size=$(stat -c '%s' "$f" 2>/dev/null || echo 0)
        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 Trash -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 (Linux).
    NCPU=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 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 virtually every Linux. 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[*]}")
# Also match lockfile-formatted occurrences (no quotes around the name)
NPM_LOOSE_RE=$(IFS='|'; echo "${BAD_NPM[*]}")

for root in "${SCAN_ROOTS[@]}"; do
    [ -d "$root" ] || continue
    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 Trash -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_LOOSE_RE" "$f" >/dev/null 2>&1; then
            hit "Malicious npm dependency in: $f"
        fi
    done <<< "$pkgs"
done
progress_clear

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

    if command -v journalctl >/dev/null 2>&1; then
        if [ "$(id -u)" -eq 0 ]; then
            info "Searching systemd journal for trongrid/aptoslabs/bsc-dataseed references..."
            # Self-match avoidance — filter out lines containing our own PID.
            # journalctl on most desktop boxes only retains a few days; document this.
            # On servers with persistent storage, retention can be much longer.
            journalctl --since '30 days ago' --no-pager 2>/dev/null \
                | grep -E 'trongrid|aptoslabs|bsc-dataseed' \
                | grep -v "\[$SELF_PID\]" \
                | grep -v "polinrider_hunt" \
                | head -20 > "$TMPDIR_HUNT/log_hits.txt"
            if [ -s "$TMPDIR_HUNT/log_hits.txt" ]; then
                check "journalctl 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 journalctl"
            fi
            warn "journalctl on most desktop installs only retains a few days; absence is not proof of absence"
        else
            warn "Re-run with sudo for journalctl search"
        fi
    else
        warn "journalctl not available — Section 10 skipped (sysvinit/syslog hosts: check /var/log/syslog* manually)"
    fi
fi

# ===================================================================
# Summary
# ===================================================================
echo
echo "=========================================="
[ "$QUIET" = 0 ] && [ -z "$FOLDER" ] && {
    echo "Companion checks (this script is campaign-specific — these are not):"
    echo "  rkhunter / chkrootkit — generic Linux rootkit detection"
    echo "  Lynis      — system hardening + integrity audit"
    echo "  auditd     — kernel audit subsystem (if installed)"
    echo "  Run a baseline diff with rkhunter --propupd before AND after this script."
    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 host."
    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 (dd or LiME for memory)."
    echo "  3. Capture: ps -eo pid,ppid,user,etime,command > ps_\$(date +%s).txt"
    echo "  4. Capture: ss -tnpa > sockets_\$(date +%s).txt"
    echo "  5. Capture: journalctl --since '30 days ago' > journal_\$(date +%s).txt (sudo)"
    echo "  6. For units found, copy the unit file AND the binary it points at"
    echo "     before disabling: cp <unit> ~/forensics/  ; same for the target."
    echo "  7. Then disable: systemctl --user disable <unit> && rm <unit>"
    echo "                or systemctl disable <unit> for system units (root)"
    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, GPG keys."
    echo "  9. If suspicion is high, wipe and reinstall. Persistence techniques"
    echo "     used by this campaign can include kernel modules and SUID binaries"
    echo "     on Linux — full reinstall is the only confident remediation."
    echo
    echo "Coordinate with: security@dhis2.org"
    exit 1
fi
