#!/usr/bin/env bash
#
# HostSeba NOC Agent — daily security scan
#
# Driven by a real cryptojacking incident on a Webuzo box where a user
# ('kinarayc') ran `cpu-logind` from /var/tmp/ at 474% CPU mining Monero.
# Imunify360 and cpGuard both missed it. This script runs the patterns
# that *would* have caught it on day one:
#
#   1. high-CPU user processes whose binary lives in an unusual path
#   2. executables under /var/tmp, /dev/shm, /home/*/.cache, /home/*/.config
#   3. user crontabs pointing to a user-home binary
#   4. .bashrc / .profile / .bash_profile installing executable hooks
#   5. outbound TCP to known mining-pool ports
#   6. process names mimicking system services (kworker-foo, cpu-logind, ...)
#
# Severity is decided server-side — the agent only identifies *categories*
# so a compromised agent cannot under-report.
#
# Cadence: daily via /etc/cron.d/hostseba-noc-hosting (entry below). Can
# also be triggered on-demand from the operator dashboard.

set -uo pipefail

# ---------- Load config (cron runs without inherited env) ----------
CONFIG_FILE="/etc/hostseba-noc/agent.conf"
[[ -r "$CONFIG_FILE" ]] || { echo "agent-security: config not readable: $CONFIG_FILE" >&2; exit 78; }
# shellcheck disable=SC1090
. "$CONFIG_FILE"

: "${BACKEND_URL:?BACKEND_URL missing in config}"
: "${SERVER_ID:?SERVER_ID missing in config}"
: "${AGENT_SECRET:?AGENT_SECRET missing in config}"

ENDPOINT="$BACKEND_URL/api/agent/security-scan"
AGENT_VERSION="${AGENT_VERSION:-1.0.0}"
TRIGGER="${1:-scheduled}"

# Anything below this is a system / service account on cPanel /
# AlmaLinux / Ubuntu. We don't scan their processes — false positive rate
# is too high (chrony, postfix etc. legitimately use cpu under load).
SYSTEM_USER_RE='^(root|daemon|bin|sys|sync|games|man|lp|mail|news|uucp|proxy|www-data|backup|list|irc|gnats|nobody|_apt|systemd-.+|syslog|messagebus|_chrony|sshd|polkitd|dbus|postfix|dovecot|mysql|mailnull|named|ftp|exim|nscd|hsphere|dovenull|popuser|nagios|postgres|nginx|apache|cpanel|webuzo|mailman|tomcat|redis|memcached|clamav|nrpe|munin|amavis)$'

# Well-known Monero / generic miner pool ports. CryptoNight pools use
# 3333/4444/5555/7777; some prefer 14444/14433/19999. Plenty of false
# positives possible (random app uses 5555) but combined with the file
# context it's a strong signal.
MINING_POOL_PORTS_RE='^(3333|4444|5555|6666|7777|8888|9999|14444|14433|19999|20480)$'

# Locations where executables are almost never legitimately placed by a
# customer account. Anything chmod +x in here on a shared-hosting box
# is worth a closer look.
SUSPICIOUS_DIRS=(/var/tmp /dev/shm /tmp)
SUSPICIOUS_USER_SUBDIRS=(.cache .config/cron .config/.cache .local/share/.cache)

# ---------- JSON helpers (no jq dependency) ----------
json_escape() {
    local s="$1"
    s="${s//\\/\\\\}"
    s="${s//\"/\\\"}"
    s="${s//$'\n'/\\n}"
    s="${s//$'\r'/\\r}"
    s="${s//$'\t'/\\t}"
    printf '%s' "$s"
}
js() { printf '"%s"' "$(json_escape "$1")"; }      # quote a string
jv() { local v="${1:-}"; [[ -z "$v" ]] && printf 'null' || printf '%s' "$v"; }  # raw value or null

# ---------- Forensic gatherers ----------

# Echo SHA-256 of a file, or empty on failure (missing file / no perms).
file_hash() {
    [[ -r "$1" ]] || return 0
    sha256sum "$1" 2>/dev/null | awk '{print $1}'
}

# "0644" style octal mode.
file_mode() {
    [[ -e "$1" ]] || return 0
    stat -c '%a' "$1" 2>/dev/null
}

file_size() {
    [[ -e "$1" ]] || { printf ''; return 0; }
    stat -c '%s' "$1" 2>/dev/null
}

# Who owns this file in /etc/passwd terms.
file_user() {
    [[ -e "$1" ]] || return 0
    stat -c '%U' "$1" 2>/dev/null
}

# Append one finding object to FINDINGS_BUF. Caller passes a series of
# `key=value` pairs; values are auto-string-escaped except for the bare
# numeric / null helpers below.
F_SEP=""
FINDINGS_BUF=""
finding_push() {
    local category="$1" payload="$2"
    FINDINGS_BUF+="$F_SEP{\"category\":\"$category\"$payload}"
    F_SEP=","
}

# ---------- 1. High-CPU user processes ----------
# Catches the original incident: `cpu-logind` at 474% CPU under user
# 'kinarayc'. Filters out system accounts so we don't alarm on legit
# stuff like MySQL or php-fpm at sustained CPU.
scan_high_cpu_processes() {
    # ps -eo with no header. Sort by %CPU desc, take top 20 user processes.
    ps -eo user:32,pid,pcpu,rss,comm,cmd --no-headers --sort=-pcpu 2>/dev/null \
      | head -n 40 | while read -r user pid pcpu rss comm cmd; do
        # Strip whitespace
        local u="${user// /}"
        # Skip system users
        [[ "$u" =~ $SYSTEM_USER_RE ]] && continue
        # Need a high-cpu threshold — 50% sustained is already abnormal
        # on a shared box. The original miner was at 474%.
        awk -v p="$pcpu" 'BEGIN{exit !(p+0 >= 50)}' || continue
        # Resolve the running binary path so we can hash it
        local exe
        exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null)
        local hash="" size="" mode=""
        if [[ -n "$exe" && -r "$exe" ]]; then
            hash=$(file_hash "$exe")
            size=$(file_size "$exe")
            mode=$(file_mode "$exe")
        fi
        # cmdline from /proc to get full args
        local cmdline=""
        if [[ -r "/proc/$pid/cmdline" ]]; then
            cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" | head -c 1000)
        fi

        # Two paths into findings depending on what we saw:
        # - binary in suspicious dir → cryptojack_process (highest signal)
        # - binary not under any normal $PATH dir → also cryptojack_process
        # - binary fine but process name mimics system → masqueraded_process
        local category="cryptojack_process"
        if [[ -n "$exe" ]]; then
            case "$exe" in
                /var/tmp/*|/dev/shm/*|/tmp/*) category="cryptojack_process" ;;
                /home/*/.cache/*|/home/*/.config/*) category="cryptojack_process" ;;
                /usr/bin/*|/usr/sbin/*|/usr/local/bin/*|/usr/local/sbin/*|/bin/*|/sbin/*|/opt/*)
                    # Legit-looking path. But the process name matters —
                    # `cpu-logind`, `kworker-foo`, `sshd-kill` are classic
                    # miner disguises that LIVE in /var/tmp but the user
                    # symlinked them somewhere else.
                    if [[ "$comm" =~ ^(cpu-logind|kworker-|kthreadd-|sshd-kill|systemd-network-helper)$ ]]; then
                        category="masqueraded_process"
                    else
                        # Legit binary at sustained high CPU under user
                        # account. Don't alarm — could be a benchmark,
                        # video transcode, etc.
                        continue
                    fi
                    ;;
                *) category="cryptojack_process" ;;
            esac
        fi

        local p=",\"unix_user\":$(js "$u")"
        p+=",\"file_path\":$(js "${exe:-}")"
        [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
        [[ -n "$size" ]] && p+=",\"file_size_bytes\":$size"
        [[ -n "$mode" ]] && p+=",\"file_permissions\":\"$mode\""
        p+=",\"process_pid\":$pid"
        p+=",\"process_name\":$(js "$comm")"
        p+=",\"process_cmdline\":$(js "$cmdline")"
        p+=",\"process_cpu_percent\":$pcpu"
        p+=",\"process_rss_kb\":$rss"
        p+=",\"evidence\":{\"detector\":\"high_cpu_user_process\",\"threshold_percent\":50}"
        finding_push "$category" "$p"
    done
}

# ---------- 2. Suspicious binaries on disk ----------
# Walks the dirs where customers shouldn't be dropping executables.
# Lightweight — we only stat files > 50KB to skip noise like shell
# fragments. Walks each dir with -maxdepth 4 to avoid runaway scans.
scan_suspicious_binaries() {
    local d
    for d in "${SUSPICIOUS_DIRS[@]}"; do
        [[ -d "$d" ]] || continue
        # find executables, exclude trivial cron noise, cap to recent files
        find "$d" -xdev -type f -executable -size +50k -mtime -30 \
             -not -name 'core.*' -not -name '*.log' \
             -maxdepth 4 2>/dev/null | head -n 50 | while read -r f; do
            local u; u=$(file_user "$f"); [[ -z "$u" ]] && continue
            [[ "$u" =~ $SYSTEM_USER_RE ]] && continue
            local hash size mode
            hash=$(file_hash "$f"); size=$(file_size "$f"); mode=$(file_mode "$f")
            local p=",\"unix_user\":$(js "$u")"
            p+=",\"file_path\":$(js "$f")"
            [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
            [[ -n "$size" ]] && p+=",\"file_size_bytes\":$size"
            [[ -n "$mode" ]] && p+=",\"file_permissions\":\"$mode\""
            p+=",\"evidence\":{\"detector\":\"executable_in_suspicious_dir\",\"dir\":$(js "$d")}"
            finding_push "suspicious_binary" "$p"
        done
    done

    # Walk per-user .cache / .config/cron subdirs — common miner stash
    # location (looks innocuous in ls).
    local home sub
    for home in /home/*; do
        [[ -d "$home" ]] || continue
        local user; user=$(basename "$home")
        [[ "$user" =~ $SYSTEM_USER_RE ]] && continue
        for sub in "${SUSPICIOUS_USER_SUBDIRS[@]}"; do
            local dir="$home/$sub"
            [[ -d "$dir" ]] || continue
            find "$dir" -xdev -type f -executable -size +50k \
                 -maxdepth 3 2>/dev/null | head -n 20 | while read -r f; do
                local hash size mode
                hash=$(file_hash "$f"); size=$(file_size "$f"); mode=$(file_mode "$f")
                local p=",\"unix_user\":$(js "$user")"
                p+=",\"file_path\":$(js "$f")"
                [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
                [[ -n "$size" ]] && p+=",\"file_size_bytes\":$size"
                [[ -n "$mode" ]] && p+=",\"file_permissions\":\"$mode\""
                p+=",\"evidence\":{\"detector\":\"executable_in_user_cache_dir\",\"dir\":$(js "$sub")}"
                finding_push "suspicious_binary" "$p"
            done
        done
    done
}

# ---------- 3. User crontabs pointing to user-home binaries ----------
# Reads /var/spool/cron/<user> (RH style) and /var/spool/cron/crontabs/<user>
# (Debian style). A persistence cron usually runs every minute or @reboot
# and points at a user-home binary.
scan_persistence_crons() {
    local d crontabs_dir
    for crontabs_dir in /var/spool/cron /var/spool/cron/crontabs; do
        [[ -d "$crontabs_dir" ]] || continue
        for d in "$crontabs_dir"/*; do
            [[ -f "$d" ]] || continue
            local user; user=$(basename "$d")
            [[ "$user" =~ $SYSTEM_USER_RE ]] && continue
            # Look for lines referencing /home/$user/, /var/tmp/, /dev/shm/, /tmp/
            grep -vE '^\s*(#|$)' "$d" 2>/dev/null | while IFS= read -r line; do
                # Match a path component that looks like a user-owned executable
                if [[ "$line" =~ (/home/[^[:space:]]+|/var/tmp/[^[:space:]]+|/dev/shm/[^[:space:]]+|/tmp/[^[:space:]]+) ]]; then
                    local target="${BASH_REMATCH[1]}"
                    # Trim trailing > redirects / args
                    target="${target%%>*}"; target="${target%% *}"
                    local hash="" size="" mode=""
                    if [[ -r "$target" ]]; then
                        hash=$(file_hash "$target")
                        size=$(file_size "$target")
                        mode=$(file_mode "$target")
                    fi
                    local p=",\"unix_user\":$(js "$user")"
                    p+=",\"file_path\":$(js "$target")"
                    [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
                    [[ -n "$size" ]] && p+=",\"file_size_bytes\":$size"
                    [[ -n "$mode" ]] && p+=",\"file_permissions\":\"$mode\""
                    p+=",\"evidence\":{\"detector\":\"crontab_to_user_binary\",\"crontab_line\":$(js "$line")}"
                    finding_push "persistence_cron" "$p"
                fi
            done
        done
    done
}

# ---------- 4. Shell-init hooks (.bashrc / .profile / .bash_profile) ----------
# Most miners drop one of:
#   nohup /var/tmp/foo &>/dev/null &
#   /home/$user/.cache/foo --start
# into ~/.bashrc so they restart on every shell login. We scan for those
# patterns in every customer home.
scan_persistence_shell() {
    local home
    for home in /home/*; do
        [[ -d "$home" ]] || continue
        local user; user=$(basename "$home")
        [[ "$user" =~ $SYSTEM_USER_RE ]] && continue
        local rc
        for rc in .bashrc .bash_profile .profile .zshrc; do
            local f="$home/$rc"
            [[ -r "$f" ]] || continue
            grep -nE '(/var/tmp/|/dev/shm/|/tmp/|\.cache/|\.config/.+/cron)' "$f" 2>/dev/null \
              | grep -vE '^\s*#' \
              | head -n 5 \
              | while IFS=: read -r lineno line; do
                local p=",\"unix_user\":$(js "$user")"
                p+=",\"file_path\":$(js "$f")"
                p+=",\"evidence\":{\"detector\":\"shell_init_hook\",\"line\":$lineno,\"snippet\":$(js "$line")}"
                finding_push "persistence_shell" "$p"
            done
        done
    done
}

# ---------- 5. Outbound TCP to known mining-pool ports ----------
# `ss -tnp` is the modern equivalent of netstat. State 'ESTAB' means an
# active connection. We filter remote ports against the pool list.
scan_outbound_mining() {
    command -v ss >/dev/null 2>&1 || return 0
    ss -tnpH state established 2>/dev/null | while read -r recvq sendq local peer rest; do
        # peer is "ip:port"; split on last colon to handle IPv6
        local peer_ip="${peer%:*}" peer_port="${peer##*:}"
        [[ -z "$peer_port" ]] && continue
        [[ "$peer_port" =~ $MINING_POOL_PORTS_RE ]] || continue
        # Extract pid + comm from the 'users:(("comm",pid=N,fd=M))' field
        local pid="" comm="" user=""
        if [[ "$rest" =~ users:\(\(\"([^\"]+)\",pid=([0-9]+) ]]; then
            comm="${BASH_REMATCH[1]}"
            pid="${BASH_REMATCH[2]}"
            user=$(stat -c '%U' "/proc/$pid" 2>/dev/null)
            [[ "$user" =~ $SYSTEM_USER_RE ]] && continue
        fi
        local exe="" hash="" cmdline=""
        if [[ -n "$pid" ]]; then
            exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null)
            [[ -n "$exe" ]] && hash=$(file_hash "$exe")
            [[ -r "/proc/$pid/cmdline" ]] && cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" | head -c 1000)
        fi
        local p=""
        [[ -n "$user" ]] && p+=",\"unix_user\":$(js "$user")"
        [[ -n "$exe" ]]  && p+=",\"file_path\":$(js "$exe")"
        [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
        [[ -n "$pid" ]]  && p+=",\"process_pid\":$pid"
        [[ -n "$comm" ]] && p+=",\"process_name\":$(js "$comm")"
        [[ -n "$cmdline" ]] && p+=",\"process_cmdline\":$(js "$cmdline")"
        p+=",\"remote_ip\":$(js "$peer_ip")"
        p+=",\"remote_port\":$peer_port"
        p+=",\"evidence\":{\"detector\":\"established_to_mining_port\",\"local\":$(js "$local")}"
        finding_push "outbound_mining" "$p"
    done
}

# ---------- 6. Masqueraded process names ----------
# Even without high CPU, a process literally named like a system service
# but running under a user account is malware ~100% of the time on
# shared hosting.
scan_masqueraded_processes() {
    local mimic_re='^(cpu-logind|kworker-[a-z0-9]+|kthreadd-[a-z0-9]+|sshd-kill|systemd-network-helper|systemd-private-foo)$'
    ps -eo user:32,pid,comm --no-headers 2>/dev/null | while read -r user pid comm; do
        local u="${user// /}"
        [[ "$u" =~ $SYSTEM_USER_RE ]] && continue
        [[ "$comm" =~ $mimic_re ]] || continue
        local exe; exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null)
        local hash=""
        [[ -n "$exe" ]] && hash=$(file_hash "$exe")
        local p=",\"unix_user\":$(js "$u")"
        p+=",\"process_pid\":$pid"
        p+=",\"process_name\":$(js "$comm")"
        [[ -n "$exe" ]]  && p+=",\"file_path\":$(js "$exe")"
        [[ -n "$hash" ]] && p+=",\"file_hash_sha256\":\"$hash\""
        p+=",\"evidence\":{\"detector\":\"masqueraded_name\"}"
        finding_push "masqueraded_process" "$p"
    done
}

# ---------- Host context summary ----------
host_summary() {
    local load uptime cpu_count
    load=$(awk '{print $1","$2","$3}' /proc/loadavg 2>/dev/null)
    uptime=$(awk '{print int($1)}' /proc/uptime 2>/dev/null)
    cpu_count=$(nproc 2>/dev/null || echo 1)
    printf '{"load_avg":"%s","uptime_seconds":%s,"cpu_count":%s,"scanned_at":"%s"}' \
        "${load:-}" "${uptime:-0}" "${cpu_count:-1}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}

# ---------- Build + post payload ----------
main() {
    scan_high_cpu_processes
    scan_suspicious_binaries
    scan_persistence_crons
    scan_persistence_shell
    scan_outbound_mining
    scan_masqueraded_processes

    local started_at; started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    local summary; summary=$(host_summary)
    local payload
    payload="{\"trigger\":$(js "$TRIGGER"),\"started_at\":\"$started_at\","
    payload+="\"agent_version\":$(js "$AGENT_VERSION"),"
    payload+="\"host_summary\":$summary,"
    payload+="\"findings\":[$FINDINGS_BUF]}"

    curl -sS -m 30 \
        -H "X-Server-Id: $SERVER_ID" \
        -H "X-Agent-Secret: $AGENT_SECRET" \
        -H "Content-Type: application/json" \
        -H "User-Agent: HostSeba-NOC-Agent/$AGENT_VERSION" \
        -d "$payload" \
        "$ENDPOINT" 2>/dev/null
}

main "$@"
