#!/usr/bin/env bash
# ==========================================================
# 运行时安全巡检与修补脚本 (跨发行版增强版 v4 / Airy Enhanced)
#
# 覆盖:
#   PHP  : CVE-2026-109618 / 109619 缓解 + 版本安全审计
#   Java : 多 JDK 版本发现 + LTS/EOL 安全审计
#   Node.js : 版本发现 + 安全版本基线审计
#   Python  : 版本发现 + EOL 审计
#
# 适用:
#   发行版: CentOS/RHEL/Rocky/Alma / Debian/Ubuntu /
#           openSUSE / Alpine (以及其他 systemd / SysV 发行版)
#   面板/环境: AppNode / 宝塔(bt) / lnmp / 官方源 / 源码编译
#
# 模式:
#   check   仅检查 (PHP 缓解项 + 多运行时版本审计)
#   patch   修补 PHP 缓解项 + 给出/可选执行多运行时系统升级
#   all     检查 + (发现问题时)修补           [默认]
#   menu    交互式菜单                        [无参数运行时]
#   ids     主机入侵检测 (账号/SSH/反弹shell/持久化/WebShell/日志 等)
#
# 常用参数:
#   --yes, -y      非交互模式下所有确认默认选 "是"
#   --quiet        精简输出 (仍记录日志)
#   --no-backup    跳过备份 (不推荐)
#   --dry-run      只展示将要执行的操作，不真正修改
#   --no-runtime   跳过 Java/Node/Python 运行时审计 (仅 PHP)
#   --no-ir        跳过 CVE 相关入侵痕迹排查 (不影响 ids 模式)
#   --patch-runtime 连 Java/Node/Python 也执行系统级升级 (默认仅打印命令)
#   --fast/--json  仅 ids 模式生效: --fast 只跑高危项 / --json 输出机器可读
#
# 用法示例:
#   bash php_cve_fix.sh                # 进入交互菜单
#   bash php_cve_fix.sh all            # 检查并自动修补
#   bash php_cve_fix.sh check -y       # 静默检查
#   bash php_cve_fix.sh patch --dry-run
#   bash php_cve_fix.sh ids            # 主机入侵检测 (完整)
#   bash php_cve_fix.sh ids --fast    # 只跑高危项
#   WEB_ROOT=/data/www bash php_cve_fix.sh ids   # 加扫 WebShell
#
# 版权声明:
#   Copyright (c) 2026 Airy Xu. 保留所有权利。
#   本脚本仅供安全加固与入侵排查之合法用途，使用风险自负。
# ==========================================================

# 注意: 不使用 set -e，改为显式错误处理，避免 grep 返回 1 时误退出
set -uo pipefail

# ==========================================================
# 0. 基础配置 / 全局变量
# ==========================================================
SCRIPT_VERSION="4.0"
IDS_VERSION="1.5.2"
COPYRIGHT="Copyright (c) 2026 Airy Xu. 保留所有权利。"
CVE_LIST="CVE-2026-109618 / CVE-2026-109619"
BACKUP_ROOT="/root/php_cve_patch_backup"
BACKUP_DIR=""
LOG_FILE="/var/log/php_cve_fix.log"
DRY_RUN=0
NO_BACKUP=0
ASSUME_YES=0
QUIET=0
NO_RUNTIME=0
RUNTIME_UPGRADE=0
IR_RAN=0
IDS_ARGS=""
NO_IR=0

# 颜色 (非 TTY 时禁用)；使用 $'...' 使其为真正的 ESC 字符，
# 这样 echo -e 与 read -p 都能正确显示，而非打印字面 \033
if [ -t 1 ]; then
    RED=$'\033[0;31m'; YELLOW=$'\033[1;33m'; GREEN=$'\033[0;32m'
    CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
    RED=''; YELLOW=''; GREEN=''; CYAN=''; BOLD=''; NC=''
fi

# 交互读取来源：管道运行(curl|bash)时 stdin 是脚本本身，
# 必须从真实终端 /dev/tty 读用户输入，否则会读到脚本字节。
if [ -t 0 ]; then
    IN_FD=/dev/stdin
elif [ -r /dev/tty ]; then
    IN_FD=/dev/tty
else
    IN_FD=/dev/null   # 完全无终端(如 cron)：交互读取无输入，依赖默认/超时
fi

# 结果统计
PASS=0; WARN_COUNT=0; FAIL_COUNT=0
# 用于汇总表
declare -a REPORT_VER; declare -a REPORT_BIN; declare -a REPORT_RESULT

# 系统信息 (后续填充)
OS_ID=""; OS_VER=""; PKG=""; INIT=""; ARCH=""

# 多 PHP 实例容器
declare -a P_BIN P_VER P_INI P_SAPI P_POOL P_SVC

# 多运行时审计容器 (php/java/node/python)
declare -a R_TYPE R_NAME R_VER R_PATH R_STATUS R_ADVICE

# ----------------------------------------------------------
# 安全版本基线快照 (知识快照，请按官方公告定期更新!)
# 仅用于离线对比，不替代官方安全公告 / 渠道核实。
# ----------------------------------------------------------
declare -A RT_SAFE=(
    [php]="8.2.35"        # PHP: 建议 >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12
    [node]="20.18.0"      # Node.js: 建议 >= 20.18 (LTS) 或 22.x LTS
    [python3]="3.8.0"     # Python: 3.7 已 EOL，建议 >= 3.8
)
# Java 已停止公开更新的主版本 (EOL 高危)
declare -A JAVA_EOL=( [6]=1 [7]=1 )
# 版本比较: 若 $1 <= $2 返回 0 (依赖 GNU sort -V)
ver_le() {
    [ "$1" = "$2" ] && return 0
    printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1 | grep -qx "$1"
}
# 版本比较: 若 $1 >= $2 返回 0
ver_ge() { ver_le "$2" "$1"; }

# ==========================================================
# 1. 日志与输出
# ==========================================================
log() {
    local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')"
    # 写入日志前剥离 ANSI 颜色，保持日志纯净
    local clean; clean="$(printf '%s' "$*" | sed 's/\x1b\[[0-9;]*m//g')"
    [ -w "$(dirname "$LOG_FILE" 2>/dev/null)" ] && echo "[$ts] $clean" >>"$LOG_FILE" 2>/dev/null
}
info()    { echo -e "  ${CYAN}[INFO]${NC} $1"; log "INFO: $1"; }
ok()      { echo -e "  ${GREEN}[OK]${NC}   $1"; log "OK: $1"; }
warn()    { echo -e "  ${YELLOW}[WARN]${NC} $1"; log "WARN: $1"; ((WARN_COUNT++))||true; }
fail()    { echo -e "  ${RED}[FAIL]${NC} $1"; log "FAIL: $1"; ((FAIL_COUNT++))||true; }
critical(){ echo -e "  ${RED}${BOLD}[CRITICAL]${NC} $1"; log "CRITICAL: $1"; ((FAIL_COUNT++))||true; }
step()    { echo; echo -e "  ${BOLD}${CYAN}>> $1${NC}"; }

is_quiet() { [ "$QUIET" -eq 1 ]; }

# ==========================================================
# 2. 交互工具
# ==========================================================
# 询问 yes/no；完全无终端(如 cron)且无 -y 时默认拒绝；
# 管道运行(curl|bash)但有 /dev/tty 时，从终端读取，可正常交互确认。
ask_yes() {
    local prompt="$1" default="${2:-Y}"
    [ "$ASSUME_YES" -eq 1 ] && return 0
    if [ "$IN_FD" = /dev/null ]; then
        # 无可用终端：破坏性操作默认拒绝，避免误改
        return 1
    fi
    local opts="[Y/n]"; [ "$default" = "n" ] && opts="[y/N]"
    local ans=""
    read -r -p "  ${BOLD}?${NC} $prompt $opts: " ans < "$IN_FD"
    ans="${ans:-$default}"
    case "$ans" in
        y|Y|yes|YES|是) return 0 ;;
        *) return 1 ;;
    esac
}

# ==========================================================
# 3. 环境探测
# ==========================================================
detect_os() {
    OS_ID="unknown"; OS_VER=""
    if [ -r /etc/os-release ]; then
        # shellcheck disable=SC1091
        . /etc/os-release
        OS_ID="${ID:-unknown}"; OS_VER="${VERSION_ID:-}"
    fi
    ARCH="$(uname -m 2>/dev/null || echo unknown)"
}

detect_pkg() {
    PKG="unknown"
    for p in apt-get apt dnf yum zypper apk pacman; do
        if command -v "$p" >/dev/null 2>&1; then
            case "$p" in
                apt-get|apt) PKG="apt" ;;
                dnf) PKG="dnf" ;;
                yum) PKG="yum" ;;
                zypper) PKG="zypper" ;;
                apk) PKG="apk" ;;
                pacman) PKG="pacman" ;;
            esac
            break
        fi
    done
}

detect_init() {
    if [ -d /run/systemd/system ] || command -v systemctl >/dev/null 2>&1; then
        INIT="systemd"
    elif command -v service >/dev/null 2>&1; then
        INIT="sysv"
    else
        INIT="none"
    fi
}

# 重启/重载服务（跨 init）
svc() {
    local action="$1" name="$2"
    case "$INIT" in
        systemd)
            systemctl "$action" "$name" >/dev/null 2>&1 && return 0
            systemctl "$action" "${name}.service" >/dev/null 2>&1 && return 0
            return 1 ;;
        sysv)
            service "$name" "$action" >/dev/null 2>&1 && return 0
            return 1 ;;
        *)
            # 直接尝试向 php-fpm 主进程发信号
            if [ "$action" = "reload" ] || [ "$action" = "restart" ]; then
                pkill -USR2 -f "$name" >/dev/null 2>&1 && return 0
            fi
            return 1 ;;
    esac
}

# ==========================================================
# 4. PHP 多实例发现
# ==========================================================
# 提取 major.minor 版本，如 "PHP 8.1.2-pl1" -> "8.1"
php_mm() {
    local v="$1"
    echo "$v" | grep -oE 'PHP [0-9]+\.[0-9]+' | awk '{print $2}'
}

# 根据 php 二进制 + 版本尝试定位 php.ini
find_ini() {
    local bin="$1" ini=""
    ini=$("$bin" -i 2>/dev/null | sed -n 's/.*Loaded Configuration File =>\s*//p' | head -n1)
    if [ -z "$ini" ] || [ ! -f "$ini" ]; then
        local mm; mm="$(php_mm "$("$bin" -v 2>/dev/null | head -n1)")"
        for cand in \
            /etc/php.ini \
            "/etc/opt/appnode/scls/appnode-php${mm}/php.ini" \
            "/www/server/php/${mm}/etc/php.ini" \
            "/etc/php/${mm}/fpm/php.ini" \
            "/etc/php/${mm}/cli/php.ini" \
            "/etc/php/${mm}/apache2/php.ini" \
            /etc/php-fpm.conf \
            "$($bin -i 2>/dev/null | sed -n 's/.*Configuration File (php.ini) Path =>\s*//p' | head -n1)/php.ini" ; do
            [ -f "$cand" ] && { ini="$cand"; break; }
        done
    fi
    echo "$ini"
}

# 定位 FPM pool
find_pool() {
    local mm="$1" pool=""
    for cand in \
        "/etc/opt/appnode/scls/appnode-php${mm}/php-fpm.d/www.conf" \
        "/www/server/php/${mm}/etc/php-fpm.d/www.conf" \
        "/etc/php/${mm}/fpm/pool.d/www.conf" \
        /etc/php-fpm.d/www.conf \
        /etc/php-fpm.d/*.conf \
        /usr/local/etc/php-fpm.d/www.conf ; do
        # 通配可能不展开，逐个判断
        for f in $cand; do
            [ -f "$f" ] && { pool="$f"; break 2; }
        done
    done
    echo "$pool"
}

# 定位服务名（优先 systemd unit）
find_svc() {
    local mm="$1" cand
    local cands=(
        "php${mm}-fpm"
        "php-fpm-${mm}"
        "php-fpm"
        "rh-php$(echo "$mm"|tr -d .)-php-fpm"
    )
    # 先扫描 systemd units
    if [ "$INIT" = "systemd" ]; then
        local units; units="$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '{print $1}')"
        for c in "${cands[@]}"; do
            echo "$units" | grep -qx "${c}.service" && { echo "$c"; return; }
        done
        # 模糊匹配 php-fpm
        local u; u="$(echo "$units" | grep -E 'php.*fpm.*service' | head -n1)"
        [ -n "$u" ] && { echo "${u%.service}"; return; }
    fi
    # sysv
    for c in "${cands[@]}"; do
        if command -v "$c" >/dev/null 2>&1 || [ -e "/etc/init.d/$c" ]; then
            echo "$c"; return
        fi
    done
    echo ""
}

# 主发现流程：把每个 PHP 实例填入数组
discover_php() {
    local bins=()
    # 候选路径
    local paths=(
        /usr/bin/php /usr/local/bin/php /bin/php
        /opt/appnode/*php*/root/usr/bin/php
        /usr/local/appnode/php/*/bin/php
        /www/server/php/*/bin/php
        /usr/local/php*/bin/php
        /usr/local/lsws/lsphp*/bin/php
        /usr/local/php/bin/php
    )
    for p in "${paths[@]}"; do
        for f in $p; do
            [ -x "$f" ] && bins+=("$f")
        done
    done
    # which -a
    if command -v which >/dev/null 2>&1; then
        for f in $(which -a php 2>/dev/null); do
            [ -x "$f" ] && bins+=("$f")
        done
    fi

    # 去重
    local seen="" b
    for b in "${bins[@]:-}"; do
        [ -z "$b" ] && continue
        echo "$seen" | grep -qx "$b" && continue
        seen+="$b"$'\n'

        local ver mm ini sapi pool svc
        ver="$("$b" -v 2>/dev/null | head -n1)"
        [ -z "$ver" ] && { warn "无法执行 $b，已跳过"; continue; }
        mm="$(php_mm "$ver")"
        ini="$(find_ini "$b")"
        sapi="$("$b" -i 2>/dev/null | sed -n 's/.*Server API =>\s*//p' | head -n1)"
        pool="$(find_pool "$mm")"
        svc="$(find_svc "$mm")"

        P_BIN+=("$b"); P_VER+=("$ver"); P_INI+=("$ini")
        P_SAPI+=("$sapi"); P_POOL+=("$pool"); P_SVC+=("$svc")

        unset ver mm ini sapi pool svc
    done
}

# ==========================================================
# 5. Web 服务器发现
# ==========================================================
WEB=()
detect_web() {
    command -v nginx    >/dev/null 2>&1 && WEB+=(nginx)
    command -v openresty>/dev/null 2>&1 && WEB+=(openresty)
    command -v httpd    >/dev/null 2>&1 && WEB+=(httpd)
    command -v apache2  >/dev/null 2>&1 && WEB+=(apache2)
    command -v caddy    >/dev/null 2>&1 && WEB+=(caddy)
    [ -x /usr/local/lsws/bin/lswsctrl ] && WEB+=(lsws)
}

# ==========================================================
# 5b. 运行时 (Java / Node.js / Python) 发现与审计
# ==========================================================
# 注册一条运行时记录
reg_runtime() {
    R_TYPE+=("$1"); R_NAME+=("$2"); R_VER+=("$3"); R_PATH+=("$4")
    R_STATUS+=("$5"); R_ADVICE+=("$6")
}

# 探测 Java (含多 JDK)
detect_java() {
    local bins=()
    command -v java >/dev/null 2>&1 && bins+=(java)
    for d in /usr/lib/jvm/*/bin/java /usr/java/*/bin/java /opt/jdk*/bin/java /opt/java/*/bin/java; do
        for f in $d; do [ -x "$f" ] && bins+=("$f"); done
    done
    local seen=""
    for b in "${bins[@]:-}"; do
        [ -z "$b" ] && continue
        local raw; raw="$("$b" -version 2>&1 | head -n1)"
        # 提取引号内版本，如 "1.8.0_452" / "17.0.12" / "21.0.5"
        local v; v="$(echo "$raw" | grep -oE '"[^"]+"' | tr -d '"' | head -n1)"
        [ -z "$v" ] && v="$(echo "$raw" | grep -oE '[0-9]+(\.[0-9]+)+(_[0-9]+)?' | head -n1)"
        [ -z "$v" ] && continue
        echo "$seen" | grep -qx "$v" && continue
        seen+="$v"$'\n'
        # 判定主版本与 EOL
        local major; major="$(echo "$v" | grep -oE '^[0-9]+' | head -n1)"
        local status advice
        if [ "${JAVA_EOL[$major]:-}" = "1" ]; then
            status="高危"; advice="Java $major 已 EOL，无公开安全更新，请升级到受支持的 LTS (17 / 21)"
        elif [ "$major" -lt 17 ] 2>/dev/null; then
            status="建议"; advice="Java $major 进入维护尾期，建议升级到 LTS 17 或 21"
        else
            status="OK"; advice="Java $major 为受支持 LTS"
        fi
        reg_runtime "java" "Java $v" "$v" "$b" "$status" "$advice"
    done
}

# 探测 Node.js (含 nvm 常见路径)
detect_node() {
    local bins=()
    command -v node >/dev/null 2>&1 && bins+=(node)
    for d in "$HOME/.nvm/versions/node"/*/bin/node /usr/local/n/versions/node/*/bin/node /opt/node*/bin/node; do
        for f in $d; do [ -x "$f" ] && bins+=("$f"); done
    done
    local seen=""
    for b in "${bins[@]:-}"; do
        [ -z "$b" ] && continue
        local v; v="$("$b" -v 2>/dev/null | tr -d 'v')"
        [ -z "$v" ] && continue
        echo "$seen" | grep -qx "$v" && continue
        seen+="$v"$'\n'
        local status advice
        if ver_ge "$v" "${RT_SAFE[node]}"; then
            status="OK"; advice="Node.js $v 高于安全基线"
        else
            status="建议"; advice="Node.js $v 低于基线 ${RT_SAFE[node]}，存在已知漏洞，建议升级到 LTS 20.18+ / 22.x"
        fi
        reg_runtime "node" "Node.js $v" "$v" "$b" "$status" "$advice"
    done
}

# 探测 Python 3
detect_python() {
    local bins=()
    command -v python3 >/dev/null 2>&1 && bins+=(python3)
    for d in /usr/bin/python3.* /usr/local/bin/python3.*; do
        for f in $d; do [ -x "$f" ] && bins+=("$f"); done
    done
    local seen=""
    for b in "${bins[@]:-}"; do
        [ -z "$b" ] && continue
        local v; v="$("$b" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
        [ -z "$v" ] && continue
        echo "$seen" | grep -qx "$v" && continue
        seen+="$v"$'\n'
        local status advice
        if ver_ge "$v" "${RT_SAFE[python3]}"; then
            status="OK"; advice="Python $v 高于安全基线"
        else
            status="建议"; advice="Python $v 低于基线 ${RT_SAFE[python3]}(3.7 已 EOL)，建议升级到 3.8+"
        fi
        reg_runtime "python3" "Python $v" "$v" "$b" "$status" "$advice"
    done
}

# 汇总运行时探测 (重置后重新发现，幂等)
detect_runtimes() {
    R_TYPE=(); R_NAME=(); R_VER=(); R_PATH=(); R_STATUS=(); R_ADVICE=()
    detect_java; detect_node; detect_python
}

# 汇总运行时审计输出
audit_runtime() {
    detect_runtimes
    [ "${#R_TYPE[@]}" -eq 0 ] && { info "未发现 Java / Node.js / Python 运行时"; return; }
    step "运行时版本安全审计"
    for i in "${!R_TYPE[@]}"; do
        local t="${R_TYPE[$i]}" name="${R_NAME[$i]}" ver="${R_VER[$i]}" st="${R_STATUS[$i]}" adv="${R_ADVICE[$i]}"
        local line="  • ${name}  (${R_PATH[$i]})  [$st]"
        case "$st" in
            OK)      ok "$line" ;;
            建议)    warn "$line — $adv" ;;
            高危)    critical "$line — $adv" ;;
            *)       info "$line — $adv" ;;
        esac
    done
    echo
}

# 为运行时给出(并可选执行)系统级升级命令
patch_runtime() {
    [ "$NO_RUNTIME" -eq 1 ] && return 0
    detect_runtimes
    [ "${#R_TYPE[@]}" -eq 0 ] && { info "未发现需要升级的 Java/Node/Python 运行时"; return 0; }
    step "运行时系统级安全升级 (Java / Node.js / Python)"
    case "$PKG" in
        apt)  CMD="sudo apt-get update && sudo apt-get --only-upgrade install openjdk-17-jdk nodejs python3" ;;
        dnf)  CMD="sudo dnf update java* nodejs python3" ;;
        yum)  CMD="sudo yum update java* nodejs python3" ;;
        zypper) CMD="sudo zypper update java* nodejs python3" ;;
        apk)  CMD="sudo apk upgrade openjdk17 nodejs python3" ;;
        pacman) CMD="sudo pacman -Syu jdk17 nodejs python" ;;
        *)    CMD="" ;;
    esac
    if [ -z "$CMD" ]; then
        info "未能识别包管理器，跳过自动升级；请按下方建议手动升级。"
    else
        info "建议执行: $CMD"
        if [ "$RUNTIME_UPGRADE" -ne 1 ]; then
            info "未启用 --patch-runtime，仅展示命令，不自动执行系统级升级。"
            return
        fi
        if [ "$DRY_RUN" -eq 1 ]; then info "[dry-run] 跳过执行"; return; fi
        if ask_yes "是否现在执行上述系统级升级? (可能重启相关服务，建议先在维护窗口进行)" n; then
            eval "$CMD" && ok "系统级升级命令已执行" || warn "升级执行失败，请手动处理"
        else
            info "已跳过自动升级，请按需手动执行上述命令。"
        fi
    fi
    echo
}

# ==========================================================
# 6. 检查逻辑 (针对单个实例)
# ==========================================================
# 参数: idx
check_one() {
    local i="$1"
    local bin="${P_BIN[$i]}" ini="${P_INI[$i]}" sapi="${P_SAPI[$i]:-}" pool="${P_POOL[$i]:-}"
    local ver="${P_VER[$i]}" mm; mm="$(php_mm "$ver")"
    local lfail=0

    echo -e "  ${BOLD}PHP $mm${NC}  (${bin})"

    if [ -z "$ini" ] || [ ! -f "$ini" ]; then
        fail "未找到 php.ini (将影响修补)"
        lfail=1
    else
        info "php.ini: $ini"

        # 1. memory_limit
        local mem
        mem=$(grep -E '^[[:space:]]*;?[[:space:]]*memory_limit' "$ini" | tail -1 | awk -F= '{print $2}' | tr -d ' ')
        if [ "$mem" = "-1" ]; then
            ok "memory_limit = -1 (规避 OOM 利用路径)"
            ((PASS++))||true
        else
            fail "memory_limit 非 -1 (当前: '${mem:-未设置}') — 存在 CVE-2026-109618 利用风险"
            lfail=1
        fi

        # 2. session.upload_progress.enabled
        local prog
        prog=$(grep -E '^[[:space:]]*;?[[:space:]]*session.upload_progress.enabled' "$ini" | tail -1 | awk -F= '{print $2}' | tr -d ' ')
        if [ "$prog" = "0" ]; then
            ok "session.upload_progress.enabled = Off (规避 CVE-2026-109619)"
            ((PASS++))||true
        else
            fail "session.upload_progress.enabled 未关闭 (当前: '${prog:-On}') — 存在 CVE-2026-109619 利用风险"
            lfail=1
        fi
    fi

    # 3. mod_php (httpd/apache2)
    if echo " $sapi " | grep -qi "apache"; then
        warn "检测到 Apache SAPI (mod_php，匿名请求即可触发，高危)"
        lfail=1
    fi

    # 4. FPM pool 强制
    if [ -n "$pool" ] && [ -f "$pool" ]; then
        if grep -qE '^php_admin_value\[memory_limit\]' "$pool" 2>/dev/null; then
            ok "FPM pool 已强制 memory_limit"
            ((PASS++))||true
        else
            warn "FPM pool 未强制 php_admin_value[memory_limit] (可被代码覆盖)"
            lfail=1
        fi
    fi

    # 记录汇总
    REPORT_VER+=("$mm"); REPORT_BIN+=("$bin")
    if [ "$lfail" -eq 0 ]; then REPORT_RESULT+=("通过")
    else REPORT_RESULT+=("需处理")
    fi
    echo
}

# ==========================================================
# 7. 修补逻辑
# ==========================================================
backup_file() {
    local f="$1"
    [ "$NO_BACKUP" -eq 1 ] && return 0
    [ -f "$f" ] && cp -a "$f" "$BACKUP_DIR/$(echo "$f" | tr '/' '_').bak" 2>/dev/null
}

# 设置/追加 ini 指令 (支持注释行)
ini_set() {
    local ini="$1" key="$2" val="$3"
    if grep -qE "^[[:space:]]*;?[[:space:]]*${key}" "$ini"; then
        sed -i -E "s|^[[:space:]]*;?[[:space:]]*${key}[[:space:]]*=.*|${key} = ${val}|" "$ini"
    else
        echo "${key} = ${val}" >> "$ini"
    fi
}

patch_one() {
    local i="$1"
    local bin="${P_BIN[$i]}" ini="${P_INI[$i]}" pool="${P_POOL[$i]:-}" svc="${P_SVC[$i]:-}"
    local mm; mm="$(php_mm "${P_VER[$i]}")"
    [ -z "$ini" ] && { warn "PHP $mm 缺少 php.ini，跳过修补"; return; }

    [ "$DRY_RUN" -eq 1 ] && { info "[dry-run] 将修补 $ini / $pool"; return; }

    step "修补 PHP $mm ($ini)"
    backup_file "$ini"

    ini_set "$ini" "memory_limit" "-1"
    ini_set "$ini" "session.upload_progress.enabled" "0"
    ini_set "$ini" "post_max_size" "8M"
    ini_set "$ini" "upload_max_filesize" "4M"
    ok "php.ini 已更新"

    # FPM pool
    if [ -n "$pool" ] && [ -f "$pool" ]; then
        backup_file "$pool"
        grep -qE '^php_admin_value\[memory_limit\]' "$pool" || echo 'php_admin_value[memory_limit] = -1' >> "$pool"
        grep -qE '^php_admin_flag\[session.upload_progress.enabled\]' "$pool" || echo 'php_admin_flag[session.upload_progress.enabled] = off' >> "$pool"
        grep -qE '^pm\.max_requests' "$pool" || echo 'pm.max_requests = 500' >> "$pool"
        ok "FPM pool 已更新 ($pool)"
    fi

    # 重启服务
    if [ -n "$svc" ]; then
        if svc restart "$svc" || svc reload "$svc"; then
            ok "已重启服务: $svc"
        else
            warn "无法自动重启 $svc，请手动重启 php-fpm / web 服务"
        fi
    else
        warn "未识别到服务名，请手动重启对应的 PHP-FPM/Apache"
    fi
}

# nginx 加固：写入独立 conf.d 片段，避免直接改主配置导致语法损坏
patch_web() {
    local has_nginx=0
    for w in "${WEB[@]:-}"; do [ "$w" = "nginx" ] || [ "$w" = "openresty" ] && has_nginx=1; done
    [ "$has_nginx" -eq 0 ] && return

    local main="" confd="" snippet
    for nc in /etc/nginx/nginx.conf /usr/local/nginx/conf/nginx.conf /etc/nginx/conf/nginx.conf; do
        [ -f "$nc" ] && main="$nc" && break
    done
    [ -z "$main" ] && { warn "未找到 nginx 主配置，跳过 Web 加固"; return; }

    confd="$(dirname "$main")/conf.d"
    [ -d "$confd" ] || mkdir -p "$confd"
    snippet="$confd/php_cve_fix.conf"

    step "为 nginx 添加请求体限制 (写入 $snippet)"
    if [ "$DRY_RUN" -eq 1 ]; then info "[dry-run] 将写入 $snippet"; return; fi

    backup_file "$main"
    cat > "$snippet" <<'EOF'
# PHP CVE-2026-109618/109619 临时缓解 (由 php_cve_fix.sh 自动生成)
# 限制请求体大小，缩小上传进度攻击面
client_max_body_size 8m;
EOF

    if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
        svc reload nginx || svc restart nginx || true
        ok "nginx 配置校验通过并重载；已限制 client_max_body_size=8m"
    elif command -v openresty >/dev/null 2>&1 && openresty -t >/dev/null 2>&1; then
        svc reload openresty || svc restart openresty || true
        ok "openresty 配置校验通过并重载"
    else
        warn "nginx -t 未通过或不可用，已生成 $snippet，请手动执行 'nginx -t' 后重载"
    fi
    info "提示: session.upload_progress 的根因已在 PHP 层关闭；如需在 Web 层进一步拦截"
    info "      请求体含 PHP_SESSION_UPLOAD_PROGRESS 的报文，可参考脚本末尾建议配置 WAF/CDN 规则。"
}

# ==========================================================
# 5c. 入侵痕迹排查 (CVE-2026-109618/109619) —— 只读，不改系统
# ==========================================================
IR_SINCE="2026-10-17"   # 暴露窗口起点（蜜罐最早记录），可按实际情况修改
IR=""; HITS=""; WEB=""; WWWUSER=""; WWWUID=""; LOGS=""; PHPLOG=""
FPMCONF=""; PIDS=""

ir_red() { printf "  ${RED}[!] %s${NC}\n" "$1"; echo "$1" >>"$HITS" 2>/dev/null; }
ir_grn() { printf "  ${GREEN}[OK] %s${NC}\n" "$1"; }
ir_yel() { printf "  ${YELLOW}[?] %s${NC}\n" "$1"; }
# 把管道输出规整成整数(空值归 0)，避免 wc/grep 空输出导致 -gt 报错
to_int() { local v; v=$(grep -oE '[0-9]+' | head -1); echo "${v:-0}"; }
ir_hdr() { echo "  ########## $1 ##########"; }

# 准备环境：报告目录 + 推导 web 根 / web 用户 / 日志路径
ir_setup() {
    local base="/root"; [ "$(id -u)" -ne 0 ] && base="/tmp"
    IR="$base/ir-$(hostname)-$(date +%F_%H%M%S)"
    mkdir -p "$IR" 2>/dev/null || { IR="/tmp/ir-$(hostname)-$(date +%F_%H%M%S)"; mkdir -p "$IR" 2>/dev/null; }
    HITS="$IR/hits.txt"; : >"$HITS" 2>/dev/null
    mkdir -p "$IR/quarantine" 2>/dev/null

    # WEB 根：优先从 nginx / AppNode 配置推导
    WEB=$(grep -rhoE 'root[[:space:]]+[^;]+;' /etc/nginx/conf.d/ /etc/nginx/nginx.conf /etc/opt/appnode/ 2>/dev/null \
          | awk '{print $2}' | tr -d ';' | grep -vE '/$|html$' | head -1)
    WEB=${WEB:-/var/www/html}

    # php-fpm conf / 进程
    local pool="${P_POOL[0]:-php-fpm}"
    FPMCONF="$pool"
    PIDS=$(pgrep -f "$pool" 2>/dev/null | tr '\n' ' ')
    WWWUSER=$(ps -o user= -p "$(echo "$PIDS" | awk '{print $1}')" 2>/dev/null | tr -d ' ')
    [ -z "$WWWUSER" ] && WWWUSER=$(ps -o user= -C php-fpm 2>/dev/null | grep -v '^ *root' | head -1 | tr -d ' ')
    [ -z "$WWWUSER" ] && WWWUSER="www-data"
    WWWUID=$(id -u "$WWWUSER" 2>/dev/null || echo "")

    # 日志
    LOGS=$(find /var/log/nginx /var/log/appnode /var/log/httpd -name '*access*.log*' 2>/dev/null | tr '\n' ' ')
    PHPLOG=$(ls -t /var/log/php*-fpm*.log /var/log/php_errors.log 2>/dev/null | head -1)
}

# 只读入侵排查（A~G + 结论）
ir_check() {
    [ "$NO_IR" -eq 1 ] && return 0
    IR_RAN=1
    ir_setup
    [ "$(id -u)" -ne 0 ] && ir_yel "提示: 非 root 运行，部分日志/进程信息可能读不到，建议用 root"
    echo
    echo -e "  ${BOLD}${CYAN}>> 入侵痕迹排查 (CVE-2026-109618/109619, 只读)${NC}"
    echo "  报告目录: $IR   (结尾自动打包为 /root/ir-report.tgz)"
    echo "  环境: web根=$WEB | web用户=$WWWUSER(uid=$WWWUID) | PHP日志=${PHPLOG:-无}"
    echo "  暴露窗口: $IR_SINCE 起 至 $(date)"

    # 先锁证据（快照）
    ps auxfww >"$IR/ps.txt" 2>/dev/null
    ss -tupen >"$IR/ss.txt" 2>/dev/null
    find /tmp /var/tmp /dev/shm -ls >"$IR/tmp-ls.txt" 2>/dev/null
    ipcs -m >"$IR/shm.txt" 2>/dev/null
    ( tar czf "$IR/logs.tgz" /var/log/nginx /var/log/php* /var/log/messages* /var/log/secure* /var/log/audit/ 2>/dev/null; true ) &
    echo "$WEB" >"$IR/target.txt"

    # A. 攻击尝试
    ir_hdr "A. 攻击尝试（只说明有人试过，不代表成功）"
    if [ -n "$LOGS" ]; then
        local n; n=$(grep -hE 'PHP_SESSION_UPLOAD_PROGRESS' $LOGS 2>/dev/null | wc -l | to_int)
        [ "$n" -gt 0 ] && ir_red "access.log 含 PHP_SESSION_UPLOAD_PROGRESS: ${n} 条" || ir_grn "未见 upload_progress 字段"
        grep -hE 'PHP_SESSION_UPLOAD_PROGRESS' $LOGS 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -nr | head -10 | sed 's/^/    IP:/'
        local s; s=$(grep -hEi 'php2shell|0x4148|ph2sh_|--0x4148php2shell' $LOGS 2>/dev/null | wc -l | to_int)
        [ "$s" -gt 0 ] && ir_red "扫描器 UA/boundary 特征: $s 条" || ir_grn "未发现扫描器特征"
        printf '    --- 大 body 的 multipart POST 且返回 5xx ---\n'
        grep -hE '"POST [^"]* HTTP[^"]*" [0-9]+ (500|502|503|504) [0-9]+ "[^"]*" "[^"]*multipart' $LOGS 2>/dev/null | awk '{print $1,$7,$9}' | sort | uniq -c | sort -nr | head -10 | sed 's/^/    /'
    else
        ir_yel "未找到 nginx/appnode 访问日志，跳过 A"
    fi

    # B. 崩溃 / OOM
    ir_hdr "B. Worker 崩溃 / OOM / 内存异常（触发过的强信号）"
    if [ -n "$PHPLOG" ]; then
        local o; o=$(grep -cE 'Allowed memory size of [0-9]+ bytes exhausted|Out of memory|Unknown on line 0' "$PHPLOG" 2>/dev/null | to_int)
        [ "$o" -gt 0 ] && ir_red "PHP OOM/内存异常: $o 次" || ir_grn "PHP 日志无 OOM"
        grep -E 'exhausted|Out of memory|Unknown on line 0' "$PHPLOG" 2>/dev/null | tail -5 | sed 's/^/    /'
    fi
    local c; c=$(journalctl --since="$IR_SINCE" 2>/dev/null | grep -E 'php-fpm|appnode-php' | grep -cE 'segfault|SIGSEGV|SIGABRT|exited on signal' | to_int)
    [ "$c" -gt 0 ] && ir_red "journal 中 php-fpm 异常退出: $c 次" || ir_grn "journal 未见 php-fpm 信号退出"
    journalctl --since="$IR_SINCE" 2>/dev/null | grep -E 'php-fpm|appnode-php' | grep -E 'segfault|SIGSEGV|SIGABRT|exited on signal' | tail -8 | sed 's/^/    /'
    ir_yel "补充：PHP2Shell 类内存破坏可能在 core_dump 留特征，有 core 时: strings core | grep -iE 'zend_new_array|upload_progress'"
    find / -xdev -name 'core*' -newermt "$IR_SINCE" -ls 2>/dev/null | head -5 | sed 's/^/    [core] /'

    # C. 仅看 fpm worker 子进程派生（排除你的 SSH 会话）
    ir_hdr "C. 仅看 php-fpm worker 子进程派生的 shell 与下载器"
    if [ -n "$PIDS" ]; then
        local susp; susp=$(ps --ppid "$(echo "$PIDS" | tr ' ' ',')" -o pid=,user=,cmd= 2>/dev/null | grep -E 'sh|bash|curl |wget |nc |ncat|socat|base64 -d|python3? -c|/tmp/|/dev/shm/' || true)
        if [ -n "$susp" ]; then ir_red "web worker 子进程里有可疑派生:"; echo "$susp" | sed 's/^/    /'; else ir_grn "php-fpm 子进程里未见 shell / 下载器"; fi
        [ "$(ps -o user= -p "$(echo "$PIDS" | awk '{print $1}')" 2>/dev/null | tr -d ' ')" = "root" ] && \
            ir_red "php-fpm master 是 root! 降权为普通用户可大幅压低 RCE 后果" || ir_grn "php-fpm 非 root 运行"
        if command -v ausearch >/dev/null 2>&1 && [ -d /var/log/audit ]; then
            ausearch --start "$IR_SINCE" --success all -k php2shell_exec 2>/dev/null | head -20 | tee -a "$IR/audit.txt" | sed 's/^/    /'
            [ -s "$IR/audit.txt" ] && ir_red "auditd 抓到 web 用户执行过命令！见 $IR/audit.txt" || ir_grn "auditd 无命中(可能没装规则)"
        else
            ir_yel "未启用 auditd，拿不到历史进程派生记录(重启后消失，属盲区)"
        fi
    else
        ir_yel "没找到 php-fpm 进程，手动确认: pgrep -f php-fpm"
    fi

    # D. 出站连接
    ir_hdr "D. Web 用户出站连接"
    if [ -n "$WWWUID" ] && [ "$WWWUID" != "0" ] && [ "$WWWUID" != "root" ]; then
        ss -tupen 2>/dev/null | grep -E "uid=$WWWUID " >"/tmp/_ir_ss_$$.txt"
        [ -s "/tmp/_ir_ss_$$.txt" ] && { ir_red "web 用户($WWWUSER)有出站连接:"; sed 's/^/    /' "/tmp/_ir_ss_$$.txt"; } || ir_grn "web 用户无出站连接"
        rm -f "/tmp/_ir_ss_$$.txt" 2>/dev/null
    else
        ir_yel "php-fpm 以 root 跑，ss uid 过滤无效 → 改看 fpm 进程打开的 socket"
        ls -l /proc/"$(echo "$PIDS" | awk '{print $1}')"/fd 2>/dev/null | grep -E 'socket:\[' | sed 's/^/    [fpm-master fd] /'
    fi

    # E. 可疑文件 / 共享内存
    ir_hdr "E. 可疑文件 / 共享内存（剔除备份与注释）"
    printf '    --- 窗口内变动的 php（ctime，抗 timestomp）---\n'
    find "$WEB" -name '*.php' -newermt "$IR_SINCE" -ls 2>/dev/null | head -30 | tee "$IR/newphp.txt" | sed 's/^/    /'
    [ -s "$IR/newphp.txt" ] && ir_yel "窗口内变动的 php 如上，逐一看上下文"
    printf '    --- /tmp /var/tmp /dev/shm 里的可执行物 / 脚本 / .so ---\n'
    find /tmp /var/tmp /dev/shm -type f \( -perm -111 -o -name '*.php' -o -name '*.sh' -o -name '*.so' \) -ls 2>/dev/null | \
        grep -vE 'php-fpm\.sock|systemd-|/tmp/.X|ir-check|smartping' | tee "$IR/tmpfiles.txt" | sed 's/^/    /'
    [ -s "$IR/tmpfiles.txt" ] && ir_red "临时目录有可执行/脚本文件! 存档 $IR/tmpfiles.txt，先 cp 隔离别删"
    printf '    --- 共享内存段（root 私有的单独列）---\n'
    ipcs -m 2>/dev/null | sed 's/^/    /'
    ipcs -m 2>/dev/null | awk 'NR>3 && NF>3 && $2!="shmid" && $5=="root"' | sed 's/^/    [!root私有] /' | tee "$IR/shm-root.txt"
    [ -s "$IR/shm-root.txt" ] && ir_red "存在 root 私有的共享内存段，用 ipcs -m -i <shmid> 查属主与时间"
    printf '    --- 激活的 .user.ini / auto_prepend（跳过 .bak 与注释与空值）---\n'
    grep -rnE '^\s*(auto_prepend_file|auto_append_file|user_ini\.filename)\s*=' \
        "$WEB" /etc/opt/appnode/scls/appnode-php74/ 2>/dev/null | \
        grep -vE '\.bak[0-9]*:' | grep -vE '^\s*[;#]' | grep -vE '=\s*$' | \
        grep -vE '=\s*(php\.ini|"\s*"|;\s*$)' | tee "$IR/autoload.txt" | sed 's/^/    /'
    [ -s "$IR/autoload.txt" ] && ir_red "发现生效的 auto_prepend/append 或 user_ini 改写!" || ir_grn "无激活的后门型 ini 注入（之前的命中是 .bak/注释误报）"

    # F. 持久化 & 凭据泄露面
    ir_hdr "F. 持久化 & 凭据泄露面"
    { crontab -l -u "$WWWUSER" 2>/dev/null; grep -hE '^[^*#[:space:]]' /etc/crontab /etc/cron.d/* 2>/dev/null; } | tee -a "$IR/cron.txt" | sed 's/^/    /'
    grep -E 'passwd=|--pass=|password=' "$IR/cron.txt" 2>/dev/null | grep -v '^#' | sed 's/^/    [!!凭据泄露] /' | tee -a "$HITS"
    grep -E 'curl |wget ' "$IR/cron.txt" 2>/dev/null | grep -v '^#' | sed 's/^/    [!下载类定时任务] /'
    for h in /home/$WWWUSER/.ssh /root/.ssh /etc/opt/appnode/.ssh; do
        [ -d "$h" ] && { printf '    .ssh 目录: %s\n' "$h"; ls -l "$h"/authorized_keys 2>/dev/null | sed 's/^/      /'; }
    done
    grep -E '^[^#].*:(/bin/bash|/bin/sh|/bin/zsh)' /etc/passwd | awk -F: '{print "    可登录:",$1,$3,$7}' | tee -a "$IR/accounts.txt"

    # G. 危险函数（只看窗口内新建 + 敏感目录）
    ir_hdr "G. PHP 危险函数粗筛（只看窗口内新建 + uploads 等敏感目录）"
    find "$WEB" -name '*.php' -newermt "$IR_SINCE" -exec grep -lE 'eval\s*\(|base64_decode\s*\(|gzinflate\s*\(|shell_exec\s*\(|passthru\s*\(|system\s*\(' {} + 2>/dev/null | \
        grep -iE 'upload|cache|tmp|wp-content|wp-includes|plugins' | head -20 | sed 's/^/    [窗口内+敏感目录] /' | tee -a "$IR/obf.txt"
    [ -s "$IR/obf.txt" ] && ir_red "窗口内新建的 php 里含危险函数，且在上传/缓存目录，优先人工看" || ir_grn "窗口内新建 php 中未直接命中危险函数"

    wait 2>/dev/null
    # 结论
    ir_hdr "结论"
    echo "  报告目录 : $IR"
    echo "  命中条数 : $(wc -l <"$HITS" 2>/dev/null || echo 0)"
    ir_yel "  ★ C/D/E/F 任一条 = 按已被入侵处理：断公网、保现场、换凭据，别 reboot"
    ir_yel "  ★ 只有 A/B       = 有人试过或触发过，凭据按已泄露轮换"
    ir_yel "  ★ 全绿           ≠ 干净：该洞可只读内存不落地文件，diff 干净快照 + 轮换凭据"
    local tgt="/root/ir-report.tgz"; [ "$(id -u)" -ne 0 ] && tgt="/tmp/ir-report.tgz"
    tar czf "$tgt" -C "$(dirname "$IR")" "$(basename "$IR")" >/dev/null 2>&1 && ir_grn "已打包: $tgt" || ir_yel "打包失败(部分路径无权限)，原始报告在 $IR"
    echo
}

# 证据保全：跑完整排查(已含证据快照与打包) + 给出隔离命令（不自动改防火墙/杀进程）
ir_lock() {
    ir_check
    echo -e "  ${YELLOW}如已确认 C/D/E/F 命中，请手动执行隔离 (脚本不自动执行):${NC}"
    echo "    iptables -I INPUT -p tcp --dport 80  -j DROP"
    echo "    iptables -I INPUT -p tcp --dport 443 -j DROP"
    echo "    # 对可疑进程: kill -STOP <pid>  (保内存现场, 切勿 reboot)"
    echo "    # 可疑文件: cp 到 $IR/quarantine 后锁权限, 勿删"
    echo
}

# ==========================================================
# 8. 升级/后续建议 (按发行版 + 版本)
# ==========================================================
suggestions() {
    echo
    echo -e "  ${BOLD}后续建议 (按发行版)${NC}"
    echo "  ----------------------------------------"

    # 版本告警
    for i in "${!P_VER[@]}"; do
        local ver="${P_VER[$i]}"; mm="$(php_mm "$ver")"
        local major minor
        major="$(echo "$mm" | cut -d. -f1)"
        minor="$(echo "$mm" | cut -d. -f2)"
        if [ "$major" -le 5 ] 2>/dev/null; then
            echo -e "  ${RED}• PHP $mm 已停止维护多年，官方无安全补丁！临时缓解 ≠ 修复，请尽快迁移到 PHP 8.2+${NC}"
        elif [ "$major" -eq 7 ] && [ "$minor" -le 3 ] 2>/dev/null; then
            echo -e "  ${YELLOW}• PHP $mm 已 EOL，建议升级到 7.4.33+ 或 8.2+${NC}"
        elif [ "$major" -eq 7 ] && [ "$minor" -eq 4 ] 2>/dev/null; then
            echo -e "  ${CYAN}• PHP $mm 仍可接收安全更新，请保持更新到 7.4.33+${NC}"
        else
            echo -e "  ${CYAN}• PHP $mm 建议升级到官方修复版本: 8.2.35+ / 8.3.36+ / 8.4.27+ / 8.5.12+${NC}"
        fi
    done

    echo
    # 运行时 (Java/Node/Python) 升级建议
    if [ "$NO_RUNTIME" -eq 0 ] && [ "${#R_TYPE[@]}" -gt 0 ]; then
        echo "  运行时升级提示:"
        for i in "${!R_TYPE[@]}"; do
            [ "${R_STATUS[$i]}" = "OK" ] && continue
            echo -e "    ${YELLOW}• ${R_NAME[$i]}${NC}: ${R_ADVICE[$i]}"
        done
        echo "    Java : 优先使用厂商 LTS (Adoptium/Temurin 17 或 21)，避免 Oracle JDK 许可风险"
        echo "    Node : 使用 LTS 线路 (20/22)，可通过 n/nvm 或系统包升级"
        echo "    Py   : 用系统包或 pyenv 升级，注意依赖兼容性"
        echo
    fi

    # 检测 AppNode 面板环境
    local is_appnode=0
    echo "${P_BIN[*]:-}" | grep -q appnode && is_appnode=1
    [ -d /opt/appnode ] && is_appnode=1

    if [ "$is_appnode" -eq 1 ]; then
        echo -e "  ${YELLOW}• 检测到 AppNode 环境${NC}: PHP 由 AppNode 以 appnode-phpXX-php* 独立管理，与系统 php 包互不影响。"
        echo "    - 升级 PHP 请在 AppNode 面板中切换/更新版本，或参考 AppNode 官方文档；"
        echo "    - 切勿在启用 remi 等第三方仓库后执行 'yum update' 全量更新："
        echo "      remi-safe 的 gd3php 会与 appnode-phpXX-php-gd 依赖的 gd-last(libgd.so.3) 冲突，可能破坏 PHP。"
        echo "    - 若确需 Remi 仓库做特定安装，请限定仓库并排除 gd："
        echo "        yum --enablerepo=remi-php82 install <包> --exclude=gd\\*"
        echo "    - 已误加 remi 仓库时，可禁用以避免后续冲突："
        echo "        yum-config-manager --disable remi remi-safe remi-php*"
    else
        case "$PKG" in
            apt)
                echo "  Debian/Ubuntu 升级 PHP:"
                echo "    sudo apt-get update"
                echo "    # 建议添加 sury 仓库以获取最新版:"
                echo "    sudo apt-get install -y lsb-release ca-certificates curl"
                echo "    sudo curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg"
                echo "    echo \"deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ \$(lsb_release -sc) main\" | sudo tee /etc/apt/sources.list.d/php.list"
                echo "    sudo apt-get update && sudo apt-get --only-upgrade install 'php*-fpm' 'php*-cli'"
                ;;
            dnf)
                echo "  RHEL 8+/CentOS 8+/Rocky/Alma 升级 PHP:"
                echo "    # 启用 Remi 仓库(模块化):"
                echo "    sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-\$(rpm -E %rhel).rpm"
                echo "    sudo dnf module reset php -y && sudo dnf module enable php:remi-8.2 -y"
                echo "    sudo dnf update php\\*"
                ;;
            yum)
                echo "  CentOS 7 / RHEL 7 升级 PHP (yum, 无模块化):"
                echo "    # 方案 A (免费应急, 推荐先试) GetPageSpeed 仓库, 提供较新的 PHP 安全更新包:"
                echo "    sudo yum -y install https://extras.getpagespeed.com/release-latest.rpm"
                echo "    sudo yum --enablerepo=getpagespeed update php\\*   # 按实际启用的源名调整"
                echo "    # 方案 B Remi 仓库:"
                echo "    sudo yum install -y https://rpms.remirepo.net/enterprise/remi-release-7.rpm"
                echo "    # 仅更新到 PHP 7.4 最新: sudo yum --enablerepo=remi-php74 update"
                echo "    # 或安装 8.2(请先确认业务兼容，且与面板共存时注意依赖冲突):"
                echo "    sudo yum --enablerepo=remi-php82 install php-cli php-fpm"
                ;;
            zypper)
                echo "  openSUSE 升级 PHP:"
                echo "    sudo zypper refresh && sudo zypper update php*"
                echo "    # 或添加 server:php:extensions 仓库获取新版"
                ;;
            apk)
                echo "  Alpine 升级 PHP:"
                echo "    sudo apk update && sudo apk upgrade php*"
                ;;
            pacman)
                echo "  Arch 升级 PHP:"
                echo "    sudo pacman -Syu php php-fpm"
                ;;
        *)
            echo "  (未能识别包管理器) 请使用系统对应方式将 PHP 升级到官方修复版本。"
            ;;
    esac
    fi

    echo
    echo "  通用加固:"
    echo "    • 卸载 mod_php，改用 PHP-FPM + proxy_fcgi/ProxyPassMatch"
    echo "    • 在 CDN / SLB / WAF 层限制请求体大小(≤8MB)并拦截含 PHP_SESSION_UPLOAD_PROGRESS 的请求(返回 403)"
    echo "    • 定期更新 PHP 与依赖，关注官方安全公告"
    echo
}

# ==========================================================
# 9. 汇总
# ==========================================================
summary() {
    echo
    echo "========================================"
    echo "  检查结果汇总"
    echo "========================================"
    if [ "${#REPORT_VER[@]}" -gt 0 ]; then
        printf "  %-8s %-28s %s\n" "版本" "二进制" "状态"
        echo "  ----------------------------------------"
        for i in "${!REPORT_VER[@]}"; do
            printf "  %-8s %-28s %s\n" "${REPORT_VER[$i]}" "${REPORT_BIN[$i]}" "${REPORT_RESULT[$i]}"
        done
        echo
    fi
    echo -e "  通过: ${GREEN}${PASS}${NC}"
    echo -e "  警告: ${YELLOW}${WARN_COUNT}${NC}"
    echo -e "  失败: ${RED}${FAIL_COUNT}${NC}"
    echo

    if [ "$FAIL_COUNT" -eq 0 ] && [ "$WARN_COUNT" -eq 0 ]; then
        ok "全部检查通过，临时缓解措施已就位。"
    elif [ "$FAIL_COUNT" -eq 0 ]; then
        warn "基本安全，但仍有可改进项 (见上)。"
    else
        fail "存在未修复的安全风险，请检查上方输出并执行修补。"
    fi

    if [ "$NO_RUNTIME" -eq 0 ] && [ "${#R_TYPE[@]}" -gt 0 ]; then
        local rstat=0
        for i in "${!R_TYPE[@]}"; do [ "${R_STATUS[$i]}" != "OK" ] && rstat=1; done
        if [ "$rstat" -eq 0 ]; then
            ok "运行时 (Java/Node/Python) 版本均处于安全基线之上。"
        else
            warn "运行时存在需关注项 (见上方审计与末尾建议)。"
        fi
    fi
}

# ==========================================================
# 10. 头部 / 环境信息
# ==========================================================
print_header() {
    echo
    # 横幅按"显示宽度"对齐: 中文/全角=2列, 英文/数字=1列, 内宽 50 列
    echo "  ╔══════════════════════════════════════════════════╗"
    echo "  ║  运行时安全巡检与修补脚本 v${SCRIPT_VERSION}                   ║"
    echo "  ║  覆盖 PHP/Java/Node.js/Python | 跨发行版增强版   ║"
    echo "  ╚══════════════════════════════════════════════════╝"
    echo
    if [ "$QUIET" -eq 0 ]; then
        info "系统: ${OS_ID} ${OS_VER} (${ARCH})"
        info "包管理器: ${PKG} | init: ${INIT}"
        local webstr="无"
        [ "${#WEB[@]}" -gt 0 ] && webstr="${WEB[*]}"
        info "发现 PHP 实例: ${#P_BIN[@]} 个 | Web 服务: ${webstr} | 运行时审计: $([ "$NO_RUNTIME" -eq 1 ] && echo 关闭 || echo 开启)"
    fi
    echo
}

# ==========================================================
# 11. 参数解析
# ==========================================================
MODE="menu"
while [ $# -gt 0 ]; do
    case "$1" in
        check|patch|all|menu|ir|ir-lock|ids) MODE="$1" ;;
        --yes|-y) ASSUME_YES=1 ;;
        --quiet)  QUIET=1 ;;
        --no-backup) NO_BACKUP=1 ;;
        --dry-run) DRY_RUN=1 ;;
        --no-runtime) NO_RUNTIME=1 ;;
        --patch-runtime) RUNTIME_UPGRADE=1 ;;
        --no-ir) NO_IR=1 ;;
        --fast|--json) IDS_ARGS="$IDS_ARGS $1" ;;
        -h|--help)
            echo "用法: bash $0 [check|patch|all|menu|ir|ir-lock|ids] [--yes] [--quiet] [--no-backup] [--dry-run] [--no-runtime] [--patch-runtime] [--no-ir] [--fast|--json]"
            echo "  ids 模式: bash $0 ids [--fast] [--json]   (WEB_ROOT=/path bash $0 ids 可扫描 WebShell)"
            exit 0 ;;
        *) echo "未知参数: $1"; exit 1 ;;
    esac
    shift
done

# ==========================================================
# 12. 权限检查
# ==========================================================
detect_os; detect_pkg; detect_init

# check 模式允许非 root (只读)；patch/all 需 root
NEED_ROOT=0
[ "$MODE" = "patch" ] || [ "$MODE" = "all" ] && NEED_ROOT=1
if [ "$NEED_ROOT" -eq 1 ] && [ "$(id -u)" -ne 0 ]; then
    echo -e "  ${RED}[FAIL]${NC} 修补模式需要 root 权限，请使用: sudo bash $0 $MODE" >&2
    exit 1
fi

# 准备备份目录 (仅修补需要)
if [ "$NEED_ROOT" -eq 1 ] && [ "$NO_BACKUP" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
    BACKUP_DIR="${BACKUP_ROOT}_$(date +%Y%m%d_%H%M%S)"
    mkdir -p "$BACKUP_DIR" 2>/dev/null || { warn "无法创建备份目录 $BACKUP_DIR"; NO_BACKUP=1; }
fi

# ==========================================================
# 13. 发现环境
# ==========================================================
# ids 是主机入侵检测，不需要 PHP 环境发现
case "$MODE" in
    check|patch|all|menu|ir|ir-lock) discover_php; detect_web ;;
esac

if [ "${#P_BIN[@]}" -eq 0 ] && [ "$MODE" != "ids" ]; then
    warn "未找到任何 PHP 安装，PHP 缓解项将跳过 (如只需运行时审计可加 --no-runtime 关闭)"
fi

# ==========================================================
# 14. 交互菜单
# ==========================================================
run_menu() {
    while true; do
        echo -e "  ${BOLD}请选择操作:${NC}"
        echo "    1) 一键检查 (check)"
        echo "    2) 一键修补 (patch)"
        echo "    3) 检查并修补 (all)   [推荐]"
        echo "    4) 查看环境信息"
        echo "    5) 入侵痕迹排查 (ir)"
        echo "    6) 证据保全 (ir-lock)"
        echo "    7) 主机入侵检测 (ids)"
        echo "    8) 退出"
        read -r -p "  ${BOLD}输入 [1-8] (默认 3):${NC} " choice < "$IN_FD"
        choice="${choice:-3}"
        case "$choice" in
            1) MODE="check"; break ;;
            2) MODE="patch"; break ;;
            3) MODE="all"; break ;;
            5) MODE="ir"; break ;;
            6) MODE="ir-lock"; break ;;
            7) MODE="ids"; break ;;
            4)
                print_header
                echo "  PHP 实例详情:"
                for i in "${!P_BIN[@]}"; do
                    echo "    [$(($i+1))] ${P_VER[$i]}"
                    echo "        bin : ${P_BIN[$i]}"
                    echo "        ini : ${P_INI[$i]:-未找到}"
                    echo "        sapi: ${P_SAPI[$i]:-未知}"
                    echo "        pool: ${P_POOL[$i]:-无}"
                    echo "        svc : ${P_SVC[$i]:-未识别}"
                done
                if [ "$NO_RUNTIME" -eq 0 ]; then
                    detect_runtimes
                    echo "  运行时详情:"
                    if [ "${#R_TYPE[@]}" -eq 0 ]; then
                        echo "    未发现 Java / Node.js / Python"
                    else
                        for i in "${!R_TYPE[@]}"; do
                            echo "    • ${R_NAME[$i]}  (${R_PATH[$i]})  [${R_STATUS[$i]}]"
                        done
                    fi
                fi
                echo
                continue ;;
            8) echo "  已退出。"; exit 0 ;;
            *) echo "  无效选择，请重试。" ;;
        esac
    done
}

# 管道运行(curl|bash)但有真实终端时，仍可进入交互菜单(从 /dev/tty 读取)；
# 完全无终端(如 cron, IN_FD=/dev/null)才降级为只读 check，避免卡住。
if [ "$MODE" = "menu" ] && [ "$IN_FD" = /dev/null ]; then
    MODE="check"
fi

if [ "$MODE" = "menu" ]; then
    # 交互运行：先自动做一遍入侵痕迹排查(只读)，再给出操作菜单
    [ "$NO_IR" -eq 0 ] && ir_check
    echo
    echo -e "  ${BOLD}脚本版本: v${SCRIPT_VERSION}   |   内嵌 IDS: v${IDS_VERSION}${NC}"
    echo -e "  ${BOLD}${COPYRIGHT}${NC}"
    echo
    echo -e "  ${BOLD}${CYAN}── 入侵痕迹排查已完成（报告见上方），下面是操作菜单 ──${NC}"
    echo
    run_menu
fi

print_header

# ==========================================================
# 15. 执行
# ==========================================================
if [ "$MODE" = "check" ]; then
    step "开始检查"
    for i in "${!P_BIN[@]}"; do check_one "$i"; done
    [ "$NO_RUNTIME" -eq 0 ] && audit_runtime
    [ "$NO_IR" -eq 0 ] && [ "$IR_RAN" -eq 0 ] && ir_check

elif [ "$MODE" = "ir" ]; then
    [ "$NO_IR" -eq 0 ] && ir_check

elif [ "$MODE" = "ir-lock" ]; then
    ir_lock

elif [ "$MODE" = "ids" ]; then
    DO_IDS=1   # 延迟到文件末尾 host_ids 定义之后调用，避免 command not found

elif [ "$MODE" = "patch" ]; then
    step "开始修补"
    # 确认
    if ! ask_yes "将对 ${#P_BIN[@]} 个 PHP 实例执行修补 (自动备份到 $BACKUP_DIR)，是否继续?" Y; then
        echo "  已取消修补。"; exit 0
    fi
    for i in "${!P_BIN[@]}"; do patch_one "$i"; done
    patch_web
    [ "$NO_RUNTIME" -eq 0 ] && patch_runtime

elif [ "$MODE" = "all" ]; then
    step "开始检查"
    for i in "${!P_BIN[@]}"; do check_one "$i"; done
    [ "$NO_RUNTIME" -eq 0 ] && audit_runtime
    [ "$NO_IR" -eq 0 ] && [ "$IR_RAN" -eq 0 ] && ir_check

    if [ "$FAIL_COUNT" -gt 0 ] || [ "$WARN_COUNT" -gt 0 ]; then
        echo -e "  ${YELLOW}[INFO]${NC} 发现问题，准备自动修补..."
        if ask_yes "是否对 ${#P_BIN[@]} 个 PHP 实例执行修补 (自动备份到 $BACKUP_DIR)?" Y; then
            echo
            for i in "${!P_BIN[@]}"; do patch_one "$i"; done
            patch_web
            [ "$NO_RUNTIME" -eq 0 ] && patch_runtime
            echo; step "复检"
            PASS=0; WARN_COUNT=0; FAIL_COUNT=0
            REPORT_VER=(); REPORT_BIN=(); REPORT_RESULT=()
            for i in "${!P_BIN[@]}"; do check_one "$i"; done
            [ "$NO_RUNTIME" -eq 0 ] && audit_runtime
        else
            echo "  已跳过修补，仅展示检查结果。"
        fi
    else
        ok "无需修补，所有检查项已通过。"
    fi
fi

# ==========================================================
# 16. 收尾
# ==========================================================
# ir / ir-lock 模式自带结论，不再走 PHP 汇总与升级建议
case "$MODE" in
    check|patch|all)
        summary
        suggestions
        ;;
esac

if [ -n "$BACKUP_DIR" ] && [ "$NO_BACKUP" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
    echo "  备份位置: $BACKUP_DIR"
    echo "  如需回滚: 将对应 .bak 文件复制回原路径并重启服务即可。"
fi
case "$MODE" in
    check|patch|all) echo "  日志文件: $LOG_FILE" ;;
esac
echo

# ==========================================================
# 17. 主机入侵检测 (内嵌 IDS v1.5.2，作者 Airy Xu)
#     只读取证：不联网 / 不改系统 / 不重启 / 不杀进程 / 不删文件
#     用法: bash php_cve_fix.sh ids [--fast] [--json]
#           WEB_ROOT=/path bash php_cve_fix.sh ids   # 加扫 WebShell
# ==========================================================
host_ids() {
#!/usr/bin/env sh
# ==================================================================
# ids.sh  v1.5.2  —— Linux / 容器 / 轻量系统  单机入侵检测（只读取证版）
#
# 作者  : Airy Xu
# 邮箱  : airy@airy.cn
# 站点  : https://airy.ltd
# 许可  : BSD 3-Clause  https://opensource.org/licenses/BSD-3-Clause
# 版权  : (c) 2026 Airy Xu. 保留所有权利。
#
# 兼容: RHEL/CentOS 5-9 · Rocky/Alma/Anolis · Ubuntu 12-24 · Debian 7+ ·
#       统信UOS · 麒麟 · openEuler · Amazon Linux · Alpine/musl ·
#       容器(distroless,ubi) · WSL1/2 · OpenWrt · SysV / Upstart / OpenRC / runit
#
# 原则: 不联网 · 不改系统 · 不重启 · 不杀进程 · 不删文件 · 不依赖 GNU 扩展
#
# 用法:
#   bash /root/ids.sh                 # 完整检查
#   bash /root/ids.sh --fast          # 只跑高危项
#   WEB_ROOT=/data/www bash /root/ids.sh
#   bash /root/ids.sh --json
#   bash /root/ids.sh --version
# ==================================================================

IDS_VERSION="1.5.2"
IDS_AUTHOR="Airy Xu"
IDS_CONTACT="airy@airy.cn"
IDS_LICENSE="BSD 3-Clause"

show_version(){
  printf '\033[1;36mids.sh\033[0m v%s
  作者  : %s <%s>
  站点  : https://airy.ltd
  许可  : %s
  兼容  : RHEL/CentOS/Rocky/Alma/Anolis · Ubuntu/Debian · 统信/麒麟/openEuler
           · Amazon Linux · Alpine/musl · 容器(distroless,ubi) · WSL · OpenWrt
  原则  : 只读取证 / 不改系统 / 不联网 / 不重启 / 不杀进程
' "$IDS_VERSION" "$IDS_AUTHOR" "$IDS_CONTACT" "$IDS_LICENSE"
  return 0
}
case " $*" in *" --version"*|*" -v "*) show_version ;; esac

set +e; set +u
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"

# ---------- 能力探测 ----------
H="$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo unknown)"
TS="$(date +%F_%H%M%S 2>/dev/null || echo manual)"
OUT="/root/ids-$H-$TS"; mkdir -p "$OUT"
: > "$OUT/crit"; : > "$OUT/warn"

have(){ command -v "$1" >/dev/null 2>&1; }
if have ps; then
  if ps -p 1 -o pid= >/dev/null 2>&1; then PS_LVL=2
  elif ps aux >/dev/null 2>&1; then PS_LVL=1
  else PS_LVL=0; fi
else PS_LVL=0; fi
have journalctl && JCTL=1 || JCTL=0
have systemctl  && SYSCTL=1 || SYSCTL=0
have ss         && SS=1 || SS=0
have ipcs       && IPCS=1 || IPCS=0
have last       && LAST=1 || LAST=0
have stat       && STAT=1 || STAT=0
have readlink   && RLNK=1 || RLNK=0
have awk        && AWK=1 || AWK=0
if have grep && echo a | grep -qE 'a' 2>/dev/null; then GREP_OK=1; else GREP_OK=0; fi

# ---------- init / 发行版 / 环境 ----------
INIT="unknown"
if [ -d /run/systemd/system ] || (have systemctl && systemctl --version >/dev/null 2>&1); then
  INIT="systemd"
elif [ -x /sbin/initctl ] && initctl list >/dev/null 2>&1 2>&1; then
  INIT="upstart"
elif [ -x /sbin/rc-service ] || [ -x /usr/bin/rc-status ]; then
  INIT="openrc"
elif [ -x /usr/bin/sv ] || [ -d /etc/runit ]; then
  INIT="runit"
elif [ -f /etc/inittab ]; then
  INIT="sysv"
elif grep -q 'busybox' /proc/1/comm 2>/dev/null; then
  INIT="busybox-init"
fi
[ "$INIT" = "unknown" ] && [ -n "$(cat /proc/1/comm 2>/dev/null)" ] && INIT="$(cat /proc/1/comm):init"

DISTRO="unknown"; DISTRO_FAM="unknown"
if [ -f /etc/os-release ]; then
  DISTRO="$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-${NAME:-unknown}}")"
  _id="$(. /etc/os-release 2>/dev/null; echo "$ID" | tr A-Z a-z)"
  case "$_id" in
    centos|rhel|rocky|almalinux|alma|ol|oraclelinux|anolis|anolis_os|tencentos|tlinux|amzn|amazon) DISTRO_FAM="rhel" ;;
    ubuntu|debian|kali|linuxmint|proxmox|pve) DISTRO_FAM="debian" ;;
    uos|uniontechos|kylin|kylinos|neokylin|openuler|openeuler|euler) DISTRO_FAM="china" ;;
    alpine) DISTRO_FAM="alpine" ;;
    *) DISTRO_FAM="other" ;;
  esac
elif [ -f /etc/redhat-release ]; then DISTRO="$(cat /etc/redhat-release)"; DISTRO_FAM="rhel"
elif [ -f /etc/debian_version ]; then DISTRO="Debian $(cat /etc/debian_version 2>/dev/null)"; DISTRO_FAM="debian"
elif [ -f /etc/alpine-release ]; then DISTRO="Alpine $(cat /etc/alpine-release 2>/dev/null)"; DISTRO_FAM="alpine"
fi

CT="baremetal"
grep -qE 'docker|containerd|lxc' /proc/1/cgroup 2>/dev/null && CT="container"
[ -f /.dockerenv ] && CT="container"
grep -qiE 'kvm|qemu|google|bhyve|VBOX|vmware|hyper-v|ami:' /sys/class/dmi/id/product_name 2>/dev/null && CT="vm"
grep -qiE 'microsoft|WSL' /proc/version 2>/dev/null && CT="wsl"
grep -qE 'pve|Proxmox' /etc/hostname /proc/1/comm 2>/dev/null && CT="proxmox-lxc"

case "$DISTRO_FAM" in
  rhel)   AUTH_LOG="/var/log/secure"; CRON_LOG="/var/log/cron"; SYSLOG_LOG="/var/log/messages" ;;
  debian) AUTH_LOG="/var/log/auth.log"; CRON_LOG="/var/log/syslog"; SYSLOG_LOG="/var/log/syslog" ;;
  alpine) AUTH_LOG="/var/log/messages"; CRON_LOG="/var/log/messages"; SYSLOG_LOG="/var/log/messages" ;;
  *)      AUTH_LOG=""; CRON_LOG=""; SYSLOG_LOG="" ;;
esac
[ -n "$AUTH_LOG" ] && [ ! -f "$AUTH_LOG" ] && AUTH_LOG=""
[ -n "$CRON_LOG" ] && [ ! -f "$CRON_LOG" ] && CRON_LOG=""

# ---------- 原语 ----------
red(){  printf '\033[1;31m[CRIT] %s\033[0m\n' "$1"; printf '%s\n' "$1" >> "$OUT/crit"; }
yel(){  printf '\033[1;33m[WARN] %s\033[0m\n' "$1"; printf '%s\n' "$1" >> "$OUT/warn"; }
grn(){  printf '\033[1;32m[OK]   %s\033[0m\n' "$1"; }
hdr(){  printf '\n\033[1;36m##### %s #####\033[0m\n' "$1"; }

cnt(){
  local n
  n="$(wc -l < "$1" 2>/dev/null | tr -d ' \t' )"
  case "${n:-0}" in *[!0-9]*) echo 0;; *) echo "${n:-0}";; esac
}
safe_c(){
  if [ "$GREP_OK" = 1 ]; then
    n="$(grep -c '^ssh-' "$1" 2>/dev/null | tr -d ' \t' | tail -1)"
  else
    n="$(while IFS= read -r l; do case "$l" in ssh-*) echo x;; esac; done < "$1" | wc -l | tr -d ' \t')"
  fi
  case "${n:-0}" in *[!0-9]*) echo 0;; *) echo "$n";; esac
}
samefile(){
  [ "$RLNK" != 1 ] && [ "$STAT" != 1 ] && return 1
  [ -z "$1" ] || [ -z "$2" ] && return 1
  [ -e "$1" ] && [ -e "$2" ] || return 1
  a="$(readlink -f "$1" 2>/dev/null || echo "$1")"
  b="$(readlink -f "$2" 2>/dev/null || echo "$2")"
  [ "$a" = "$b" ] && return 0
  if [ "$STAT" = 1 ]; then
    ia="$(stat -c '%d:%i' "$a" 2>/dev/null)"; ib="$(stat -c '%d:%i' "$b" 2>/dev/null)"
    [ -n "$ia" ] && [ "$ia" = "$ib" ] && return 0
  fi
  return 1
}
pid_cmdline(){ tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null | sed 's/ *$//'; }
pid_comm(){    cat "/proc/$1/comm" 2>/dev/null; }
pid_exe(){     readlink "/proc/$1/exe" 2>/dev/null; }

# v1.5.2：运维二进制白名单（这些即使用 root/nobody 跑，也绝不判 CRIT）
is_blessed(){
  case "$1" in
    /usr/bin/curl|/usr/bin/wget|/usr/bin/smartping|/root/smartping/*|/sbin/dhclient|/usr/sbin/dhclient|\
    /usr/bin/cloud-init*|/usr/libexec/cloud-init*|/usr/sbin/cron*|/usr/sbin/sshd|/usr/lib/systemd/*|\
    /usr/bin/letsencrypt*|/opt/appnode/*|/var/opt/appnode/*|/usr/bin/php*|/usr/sbin/raid-check|\
    /usr/lib64/sa/*|/usr/bin/perl|/usr/bin/python*|/usr/bin/ruby|/usr/sbin/chkrootkit|/usr/bin/rkhunter|\
    /bin/tar|/usr/bin/zip|/usr/bin/gzip|/usr/bin/gpg*|/usr/bin/openssl|/usr/bin/ssh|/usr/bin/scp) return 0 ;;
  esac
  return 1
}

printf '\033[1;36m== ids.sh v%s · %s ==\033[0m\n' "$IDS_VERSION" "$IDS_AUTHOR"
printf '    系统: %s | %s | 内核 %s | arch %s | init=%s | 环境=%s\n' \
  "$DISTRO" "$(uname -o 2>/dev/null || echo Linux)" "$(uname -r)" "$(uname -m)" "$INIT" "$CT"
[ "$PS_LVL" = 0 ] && yel "本机无 procps-ng，进程扫描改走 /proc 直读（Alpine/容器正常）"

# ==================================================================
hdr "0. 基础信息"
printf '    主机: %s   时间: %s   已开机: ' "$H" "$TS"; uptime -p 2>/dev/null || uptime
case "$INIT" in
  systemd) systemctl is-active crond 2>/dev/null || systemctl is-active cron 2>/dev/null || echo "crond 状态未知" ;;
  openrc)  rc-status -s 2>/dev/null | grep -E 'cron|syslog' | sed 's/^/    /' || echo "rc-status 不可用" ;;
  upstart) initctl list 2>/dev/null | grep -E 'cron|rsyslog' | sed 's/^/    /' || echo "initctl 不可用" ;;
  *)       crontab -l >/dev/null 2>&1 && echo "crond: 运行中(无法精确确认)" ;;
esac
if grep -hE 'init 6|/sbin/reboot|shutdown -r' /etc/crontab /etc/cron.d/* 2>/dev/null | grep -qvE '^#'; then
  yel "本机仍配了每日重启，内存型驻留活不过 24h → 先停用，观察 72h"
else
  grn "未发现每日重启类 cron"
fi

# ==================================================================
hdr "1. 账号 / 提权面"
awk -F: '$3==0&&$1!="root"{printf "    [CRIT] 额外 UID=0 账号: %s\n",$1}' /etc/passwd >> "$OUT/crit"
if have sudo; then
  grep -rE 'NOPASSWD|!authenticate' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | sed 's/^/    [sudo] /'
else
  yel "未安装 sudo（容器/最小化系统常见，属正常）"
fi
awk -F: '($7!~/nologin|false/)&&($3>=1000||$3==0){print "    "$1,$3,$7}' /etc/passwd
getent passwd 2>/dev/null | awk -F: '$3>=1000' | cut -d: -f1 | while read -r u; do
  case "$(passwd -S "$u" 2>/dev/null | awk '{print $2}')" in LK|locked) printf '    [账号已锁] %s\n' "$u" >> "$OUT/warn" ;; esac
done
[ -s "$OUT/crit" ] || grn "无额外 root 账号"

# ==================================================================
hdr "2. SSH 密钥逐条认领（含指纹 + 孤儿账号检测）"
for d in /root /home/*; do
  [ -d "$d/.ssh" ] || continue
  f="$d/.ssh/authorized_keys"
  printf '\n    \033[1m%s\033[0m\n' "$d/.ssh"
  ls -l --time-style=+%F\ %T "$d/.ssh" 2>/dev/null | sed 's/^/      /'
  if [ ! -f "$f" ]; then
    yel "$f 不存在（账号 $(basename "$d") 家目录在，但无 authorized_keys）"
    continue
  fi
  n="$(safe_c "$f")"
  if [ "$n" -gt 1 ]; then
    red "$f 共 $n 条公钥，逐条认领："
    while IFS= read -r k; do
      [ -z "$k" ] && continue
      if have ssh-keygen; then
        fp="$(ssh-keygen -lf <(printf '%s\n' "$k") 2>/dev/null | awk '{print $1" | "$2" | "$3}')"
        printf '      %s\n' "${fp:-${k:0:64}…}"
      else
        printf '      %s…（无 ssh-keygen，无法解析指纹）\n' "${k:0:64}"
      fi
    done < "$f"
  else
    if have ssh-keygen; then fp="$(ssh-keygen -lf "$f" 2>/dev/null | awk '{print $1" | "$2" | "$3}')"; else fp=""; fi
    grn "$f 仅 1 条: ${fp:-（无 ssh-keygen）}"
  fi
  u="$(basename "$d")"
  if ! getent passwd "$u" >/dev/null 2>&1; then
    yel "孤儿 ssh 目录：账号 $u 已不存在，但 $d/.ssh 还在"
  fi
done

# ==================================================================
hdr "2b. 成功直登 root 的外网 IP（只认 IPv4，排除 ::1 / 127. / 本机自连）"
> "$OUT/root-ip.txt"
if [ "$LAST" = 1 ]; then
  last -w -F 2>/dev/null | \
    awk '/root +pts|root +tty/{
      ip=$(NF-2)
      if (ip ~ /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/ && ip !~ /^(127\.|0\.0\.0\.0)/) print ip
    }' | sort | uniq -c | sort -nr | head -15 | tee "$OUT/root-ip.txt" | sed 's/^/    /'
else
  yel "last 不可用，改从 $AUTH_LOG 提取 root 登录记录"
  if [ -n "$AUTH_LOG" ] && [ -f "$AUTH_LOG" ]; then
    grep -E 'Accepted (publickey|password|keyboard-interactive) for root' "$AUTH_LOG" 2>/dev/null | \
      grep -oE 'from [0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' | awk '{print $2}' | \
      sort | uniq -c | sort -nr | head -15 | tee "$OUT/root-ip.txt" | sed 's/^/    /'
  fi
fi
if [ -s "$OUT/root-ip.txt" ]; then
  yel "上面这些外网 IP 成功直登过 root —— 逐条核对是否你的跳板"
else
  grn "近期无外网 IP 直登 root 记录（或本机无登录审计日志）"
fi

# ==================================================================
# v1.5.2：§3 与 §3b 各自独立文件，命中直接落盘，不经 red()，斩断自污染
hdr "3. 反弹 shell / 派生进程（/proc 直读，BusyBox 安全）"
: > "$OUT/proc-crit.txt"
SELF_PID="$$"
for p in /proc/[0-9]*; do
  pid="${p#/proc/}"; [ -d "$p" ] || continue
  [ "$pid" = "$SELF_PID" ] && continue
  case "$p" in */$$ | */$PPID | */$BASHPID) continue ;; esac
  exe="$(pid_exe "$pid")"; cmd="$(pid_cmdline "$pid")"; comm="$(pid_comm "$pid")"
  case "$cmd" in *"ids.sh"*|*ir-check*|*grep\ -E*|*grep\ -e*|*grep\ --*) continue ;; esac
  [ -z "$exe" ] && continue
  argv0="$(echo "$cmd" | awk '{print $1}')"
  samefile "$exe" "$argv0" && continue
  is_blessed "$exe" && continue
  echo "$cmd" | grep -qE '(/dev/(tcp|udp)|nc .*-e|socat.*(exec|system|pty)|bash -i|sh -i|pty\.spawn|TCPSocket|mkfifo)' || continue
  printf '    pid=%s comm=%s exe=%s  cmd=%s\n' "$pid" "$comm" "$exe" "$cmd" >> "$OUT/proc-crit.txt"
done
if [ -s "$OUT/proc-crit.txt" ]; then
  printf '\033[1;31m[CRIT] 疑似反弹/派生 shell（已另存 %s）:\033[0m\n' "$OUT/proc-crit.txt"
  sed 's/^/    /' "$OUT/proc-crit.txt" >> "$OUT/crit"
else
  grn "未发现反弹 shell 特征"
fi

hdr "3b. 临时区陌生二进制（同源 argv 放行 + 运维白名单）"
: > "$OUT/tmp-crit.txt"
for p in /proc/[0-9]*; do
  pid="${p#/proc/}"; [ -d "$p" ] || continue
  exe="$(pid_exe "$pid")"; [ -z "$exe" ] && continue
  case "$exe" in
    /usr/*|/bin/*|/sbin/*|/lib*|/etc/*|/proc/*) continue ;;
    /root/smartping/*|/opt/appnode/*|/var/opt/appnode/*|/data/*|/alidata/*) continue ;;
  esac
  argv0="$(cat "$p/cmdline" 2>/dev/null | tr '\0' ' ' | awk '{print $1}')"
  samefile "$exe" "$argv0" && continue
  is_blessed "$exe" && continue
  case "$exe" in
    */tmp/*|*/var/tmp/*|*/dev/shm/*|*/run/shm/*)
      printf '    pid=%s comm=%s exe=%s\n' "$pid" "$(pid_comm "$pid")" "$exe" >> "$OUT/tmp-crit.txt" ;;
  esac
done
if [ -s "$OUT/tmp-crit.txt" ]; then
  printf '\033[1;31m[CRIT] 临时区落地的陌生进程镜像（已另存 %s）:\033[0m\n' "$OUT/tmp-crit.txt"
  sed 's/^/    /' "$OUT/tmp-crit.txt" >> "$OUT/crit"
else
  grn "无临时区落地的陌生进程镜像"
fi

# ==================================================================
hdr "4. 网络：外连 TOP + 非标准监听"
if [ "$SS" = 1 ]; then
  ss -tnp state established 2>/dev/null | \
    awk 'NR>1&&$5!~/127\.|::1/{split($5,a,":");print a[1]}' | sort | uniq -c | sort -nr | head -12 | \
    while read -r c ip; do printf '    [外连 %3s] %s\n' "$c" "$ip"; done
  ss -lntp 2>/dev/null | awk 'NR>1&&$4~/0\.0\.0\.0/{print $4,$6}' | \
    grep -vE ':(22|80|443|53|25|3306|6379|9000|8080|8443)$' | sed 's/^/    [非标监听] /' | tee "$OUT/listen.txt"
else
  yel "无 ss 命令，改读 /proc/net/tcp（只列监听，不统计外连）"
  awk 'NR>1 && $4=="01" && $2!~/00000000:0000/{
    ip=sprintf("%d.%d.%d.%d", strtonum("0x"substr($2,7,2)), strtonum("0x"substr($2,5,2)), strtonum("0x"substr($2,3,2)), strtonum("0x"substr($2,1,2)))
    printf "    [监听] %s:%d\n", ip, strtonum("0x"substr($2,10,4))
  }' /proc/net/tcp 2>/dev/null | head -20 | tee "$OUT/listen.txt"
fi
[ -s "$OUT/listen.txt" ] && yel "存在非标准监听端口，确认是不是你开的" || grn "无异常监听端口"

# ==================================================================
hdr "5. 持久化：cron / rc.local / 服务管理器"
> "$OUT/cron.raw"
grep -hE '^[^*#[:space:]]' /etc/crontab /etc/cron.d/* 2>/dev/null > "$OUT/cron.raw"
sed 's/^/    [cron] /' "$OUT/cron.raw"
grep -E '\.(configrc|config)|/tmp/|curl |wget |nc |socat |base64 -d|passwd=|password=|enc-passwd=|init 6|reboot|shutdown' "$OUT/cron.raw" | \
  while IFS= read -r line; do
    tgt="$(printf '%s' "$line" | grep -oE '(/[a-z0-9_./-]+)' | head -1)"
    if [ -n "$tgt" ] && [ -e "$tgt" ]; then
      printf '    [!CRIT] 可疑命令指向实文件: %s\n' "$line" >> "$OUT/crit"
    else
      printf '    [僵尸cron] 目标已不存在，可清理: %s\n' "$line" >> "$OUT/warn"
    fi
  done
for u in root $(ls /home 2>/dev/null); do
  crontab -l -u "$u" 2>/dev/null | grep -vE '^#|^$' | sed "s/^/    [cron $u] /"
done

if [ -x /etc/rc.d/rc.local ] || [ -x /etc/rc.local ]; then
  if grep -qvE '^[[:space:]]*#|^[[:space:]]*$' /etc/rc.d/rc.local 2>/dev/null; then
    yel "/etc/rc.local 含有效指令（请核对是否为你自己的启动项）:"
    grep -vE '^[[:space:]]*#|^[[:space:]]*$' /etc/rc.d/rc.local 2>/dev/null | sed 's/^/    /'
  fi
fi

# v1.5.2：云镜像 / 出厂服务全部沉底，只留真正需要人眼的
case "$INIT" in
  systemd)
    systemctl list-unit-files --type=service 2>/dev/null | grep -E 'enabled' | \
      grep -vE '^(sshd|network|NetworkManager|rsyslog|auditd|firewalld|crond|systemd-|rhel|centos|appnode|nginx|php|mysql|mariadb|smartping|google-|qemu|iptables|chronyd|postfix|dbus|irqbalance|kdump|lvm2|microcode|cloud-|abrt|fail2ban|acpid|autovt|blk-|dhclient|raid-check|sa2|letsencrypt|agent|dblimiter|smartd|containerd|docker|kubelet|iscsi|initramfs-|dmraid|atd|getty|rpcbind|rpc-statd|tuned|irqbalance|cpupower|numad|libvirtd|virtlogd|virtlockd)-' | \
      sed 's/^/    [自启?] /' | head -20 ;;
  openrc)
    rc-update show -v 2>/dev/null | grep -E '\[ *on *\]' | \
      grep -vE '^(sshd|nginx|php|mysql|mariadb|smartping|appnode|syslog|network|netmount|chronyd|postfix|docker|containerd)' | \
      sed 's/^/    [自启?] /' | head -20 ;;
  upstart)
    initctl list 2>/dev/null | grep -vE 'stop/waiting' | \
      grep -vE 'sshd|nginx|mysql|appnode|smartping|rsyslog|cron|cloud' | sed 's/^/    [upstart] /' | head -20 ;;
  runit)   ls /etc/service/ 2>/dev/null | sed 's/^/    [runit 服务] /' | head -20 ;;
  busybox-init|sysv)
    ls /etc/rc*.d/ 2>/dev/null | sed 's/^/    [SysV 启动链] /' | head -10
    runlevel 2>/dev/null | sed 's/^/    [runlevel] /' ;;
esac

hdr "5b. SUID / SGID（系统路径 / appnode / 国产面板 / 站点目录都放过）"
CHINA_PATH='^/(usr/(bin|sbin|lib|lib64|libexec)|bin|sbin|etc/appnode|var/opt/appnode|opt/appnode|data|alidata|usr/local)'
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | \
  grep -vE "$CHINA_PATH" | \
  while IFS= read -r f; do printf '    [SUID] %s\n' "$f"; done | tee "$OUT/suid.txt"
[ -s "$OUT/suid.txt" ] && red "非系统路径 SUID/SGID（用 stat 看 mtime）" || grn "无异常 SUID/SGID"

# v1.5.2：把 appnode-phpXX 整段、wp-includes、业务空目录一起排掉
find / -xdev -perm -o+w -type d 2>/dev/null | \
  grep -vE '^/(tmp|var/tmp|dev/shm|run|var/cache|var/spool)(/|$)' | \
  grep -vE 'WordPress|wp-content|wp-includes|/uploads|/cache$|wp-admin|/wwwroot|/htdocs|/voc$|/kk$|appnode-php|/tmp$' | \
  head -20 | while IFS= read -r d; do printf '    [全局可写?] %s\n' "$d"; done

# ==================================================================
hdr "6. 临时文件 / 共享内存"
find /tmp /var/tmp /dev/shm /run/shm -type f \( -perm -111 -o -name '*.php' -o -name '*.sh' -o -name '*.so' \) 2>/dev/null | \
  grep -vE 'php-fpm\.sock|systemd-|ids\.sh|ir-check|smartping' | \
  while IFS= read -r f; do printf '    %s\n' "$f"; done | tee "$OUT/tmpf.txt"
[ -s "$OUT/tmpf.txt" ] && red "临时目录有可执行物（先 cp 出来，别删）" || grn "临时目录无异常可执行物"
if [ "$IPCS" = 1 ]; then
  ipcs -m 2>/dev/null | awk 'NR>3 && NF>5 && $2!="shmid" && $6=="666"' | grep -qE '^[0-9]'
  [ $? = 0 ] && red "存在 666 共享内存段（任意用户可读）" || grn "无 666 共享内存段"
else
  yel "无 ipcs，改读 /proc/sysvipc/shm"
  awk 'NR>1 && $5!~/^0/ && $4~/666/{printf "    [shm 666] id=%s key=%s perms=%s bytes=%s nattch=%s\n",$1,$2,$4,$5,$6}' /proc/sysvipc/shm 2>/dev/null | tee -a "$OUT/tmpf.txt"
  [ -s "$OUT/tmpf.txt" ] || grn "无 666 共享内存段"
fi

# ==================================================================
hdr "7. WebShell 扫描（指定 WEB_ROOT 才跑）"
WEB="${WEB_ROOT:-}"
if [ -n "$WEB" ] && [ -d "$WEB" ]; then
  yel "扫描根: $WEB"
  if [ "$GREP_OK" = 1 ]; then
    grep -rnE --include='*.php' --line-number \
      '@eval|base64_decode\s*\(|gzinflate\s*\(|gzuncompress\s*\(|shell_exec\s*\(|passthru\s*\(|system\s*\(|assert\s*\(|create_function\s*\(' \
      "$WEB" 2>/dev/null | head -80 | sed 's/^/    /' | tee "$OUT/webshell.txt"
  else
    grep -rnE '@eval|shell_exec|passthru|create_function' "$WEB" 2>/dev/null | head -80 | sed 's/^/    /' | tee "$OUT/webshell.txt"
    yel "本机 grep 不支持扩展正则，已降为字符串匹配，建议装 GNU grep 后重跑"
  fi
  find "$WEB" -name '*.php' -mtime -7 2>/dev/null | \
    grep -iE 'upload|cache|tmp|plugins|themes|admin|wp-includes' | \
    while IFS= read -r f; do printf '    [7天内] %s\n' "$f"; done | tee -a "$OUT/webshell.txt"
  [ -s "$OUT/webshell.txt" ] && yel "命中危险函数/近期新增 php，人工看上下文"
else
  grn "未指定 WEB_ROOT，跳过 WebShell 扫描（例: WEB_ROOT=/data/www bash /root/ids.sh）"
fi

# ==================================================================
hdr "8. 登录证据（按发行版选日志源，指纹级去重 + 剔除本机自连）"
> "$OUT/auth.txt"
_log(){ [ -n "$1" ] && [ -f "$1" ] && grep -E 'Accepted (password|publickey|keyboard-interactive)' "$1" >> "$OUT/auth.txt"; }
_log "$AUTH_LOG"; _log "$SYSLOG_LOG"
if [ "$JCTL" = 1 ] && [ "$INIT" = "systemd" ]; then
  journalctl -u sshd --since="2026-10-17" 2>/dev/null | grep -E 'Accepted' >> "$OUT/auth.txt"
fi
if [ -s "$OUT/auth.txt" ]; then
  yel "最近成功登录（已指纹去重并剔除 ::1 / 127.，核对是否你的跳板）:"
  awk '{
    if ($0 ~ /::1/ || $0 ~ /127\.0\.0\.1/) next
    k=$0
    sub(/[a-zA-Z0-9.-]+\.airy\.cn? sshd\[[0-9]+\]: /,"sshd: ",k)
    sub(/ port [0-9]+ /," port X ",k)
    if (!(k in seen)) { seen[k]=1; print }
  }' "$OUT/auth.txt" | tail -15 | sed 's/^/    /'
else
  grn "无登录审计记录（容器/最小化系统常见；非 systemd 机请查 $AUTH_LOG）"
fi
find /etc/ssh /etc/passwd /etc/shadow -mtime -1 2>/dev/null | sed 's/^/    [24h内改动] /'
find /root/.ssh /home/*/.ssh -mmin -600 2>/dev/null | sed 's/^/    [10分钟内动过] /'

hdr "8b. 强制访问控制（SELinux / AppArmor）"
if [ -x /usr/sbin/sestatus ] || [ -x /sbin/sestatus ]; then
  sestatus 2>/dev/null | grep -E 'SELinux status|Current mode|Policy' | sed 's/^/    [SELinux] /'
elif [ -e /sys/fs/selinux/enforce ]; then
  printf '    [SELinux] enforcing=%s（sestatus 未装，读内核接口）\n' "$(cat /sys/fs/selinux/enforce 2>/dev/null)"
fi
if have aa-status; then
  aa-status 2>/dev/null | grep -E 'profiles|enforce|complain' | head -5 | sed 's/^/    [AppArmor] /'
elif [ -d /etc/apparmor.d ]; then
  yel "存在 /etc/apparmor.d 但 aa-status 未装，AppArmor 状态未知"
fi
[ "$DISTRO_FAM" = "china" ] && yel "国产系统 $DISTRO：请额外核对厂商加固策略（麒麟 secGear / 统信安全机制）"

# ==================================================================
hdr "9. 崩溃 / OOM / 异常信号"
if [ "$JCTL" = 1 ] && [ "$INIT" = "systemd" ]; then
  journalctl --since="2026-10-17" 2>/dev/null | \
    grep -Ei 'segfault|SIGSEGV|SIGABRT|exited on signal|Out of memory|traps:|denied' | \
    tail -20 | sed 's/^/    /' | tee "$OUT/kern.txt"
elif [ -f /var/log/kern.log ]; then
  grep -Ei 'segfault|oom|traps:|denied' /var/log/kern.log 2>/dev/null | tail -20 | sed 's/^/    /' | tee "$OUT/kern.txt"
else
  dmesg 2>/dev/null | tail -40 | grep -Ei 'segfault|oom|traps:|denied' | sed 's/^/    /' | tee "$OUT/kern.txt"
fi
[ -s "$OUT/kern.txt" ] && yel "有崩溃/OOM/拒绝信号" || grn "近 7 天无异常信号"
ls -1dt /var/spool/abrt/ccpp-* 2>/dev/null | head -3 | while read -r d; do
  printf '    [abrt] %s  %s\n' "$(basename "$d" | sed 's/^ccpp-//')" \
    "$(grep -m1 '^Executable:' "$d/metadata" 2>/dev/null | cut -d' ' -f2-)"
done

# ==================================================================
hdr "===== 结论 ====="
C="$(cnt "$OUT/crit")"
W="$(cnt "$OUT/warn")"
B="$(wc -c < "$OUT/crit" 2>/dev/null | tr -d ' \t')"
[ "${B:-0}" = "0" ] && C=0
[ -s "$OUT/crit" ] || C=0
printf '主机     : %s\n系统     : %s (%s, %s)\n脚本     : ids.sh v%s (%s)
报告目录 : %s\nCRIT     : %s\nWARN     : %s\n' \
  "$H" "$DISTRO" "$INIT" "$CT" "$IDS_VERSION" "$IDS_AUTHOR" "$OUT" "$C" "$W"
echo "─────────────────────────────────────────────"
if [ "$C" -gt 0 ]; then
  printf '\033[1;31m  ★ CRIT 非空 → 先隔离公网、保现场、别 reboot、换凭据，再分析取证包\033[0m\n'
elif [ -s "$OUT/crit" ]; then
  printf '\033[1;33m  ★ crit 文件异常（计数归零但内容非空），请人工看: %s\033[0m\n' "$OUT/crit"
else
  printf '\033[1;32m  ★ 本轮无 CRIT 命中\033[0m\n'
fi
if grep -hE 'init 6|/sbin/reboot' /etc/crontab /etc/cron.d/* 2>/dev/null | grep -qE '^[^#]'; then
  printf '  ⚠ 但本机仍有每日重启 → 内存驻留查不到，停掉后观察 72h 再下“干净”结论\n'
fi
printf '  检测能力: ps=%s journalctl=%s ss=%s ipcs=%s last=%s stat=%s\n' \
  "$PS_LVL" "$JCTL" "$SS" "$IPCS" "$LAST" "$STAT"
tar czf "/root/ids-$H.tgz" -C "$(dirname "$OUT")" "$(basename "$OUT")" >/dev/null 2>&1 && echo "已打包: /root/ids-$H.tgz"
if [ "$2" = "--json" ] || [ "$1" = "--json" ]; then
  printf '{"host":"%s","distro":"%s","init":"%s","env":"%s","arch":"%s","tool":"ids.sh","version":"%s","author":"%s","ts":"%s","crit":%s,"warn":%s}\n' \
    "$H" "$DISTRO" "$INIT" "$CT" "$(uname -m)" "$IDS_VERSION" "$IDS_AUTHOR" "$TS" "$C" "$W"
fi
}

# ids 模式延迟调用：host_ids 函数体已在上方定义，这里在其定义之后执行，避免 "command not found"
if [ "${DO_IDS:-0}" = 1 ]; then
    host_ids $IDS_ARGS
fi
