#!/bin/bash
#===============================================================================
# 宝塔系面板 安全自检
#
# 用途: 检测本机面板是否被植入/恢复了已知后门（bt.sb / bash.moe 团伙）
# 适用于: 本机定期巡检；也可复制到其他服务器，装完后立即跑一次
#
# 用法:  bash check_btpanel.sh            # 完整输出
#        bash check_btpanel.sh --quiet    # 只在有问题时输出（适合 cron）
#
# 退出码: 0 = 全部通过；1 = 发现可疑项
#===============================================================================

PANEL="${PANEL_PATH:-/www/server/panel}"
PYENV="$PANEL/pyenv"
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASELINE="$SELF_DIR/baseline.sha256"
QUIET=0
[ "$1" = "--quiet" ] && QUIET=1

RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; NC=$'\033[0m'
FAIL=0
out()  { [ $QUIET -eq 0 ] && echo "$@"; return 0; }
bad()  { echo "${RED}✗ $*${NC}"; FAIL=1; }
good() { out "${GRN}✓${NC} $*"; }
warn() { out "${YEL}!${NC} $*"; }

[ $QUIET -eq 0 ] && echo "=== 宝塔面板安全自检  $(date '+%F %T') ==="
[ $QUIET -eq 0 ] && echo "面板路径: $PANEL"
[ $QUIET -eq 0 ] && echo

# 恶意域名特征（bt.sb 是 bash.moe 团伙的别名域名）
EVIL='bt\.sb|bash\.moe'

#-------------------------------------------------------------------------------
out "[1/7] requests 库是否被注入 URL 劫持"
#-------------------------------------------------------------------------------
# 不写死 Python 版本目录：面板可能是 3.7 / 3.11 / 3.13 等，
# 写死会导致该项在非 3.7 面板上静默跳过（漏掉最重要的后门检测）。
APIS=$(ls -1 "$PYENV"/lib/python3.*/site-packages/requests/api.py \
             "$PYENV"/lib/python3/site-packages/requests/api.py \
             "$PYENV"/lib/site-packages/requests/api.py 2>/dev/null)
if [ -z "$APIS" ]; then
    # 兜底：全目录搜一遍
    APIS=$(find "$PYENV" -path '*site-packages/requests/api.py' 2>/dev/null | head -5)
fi
if [ -z "$APIS" ]; then
    warn "在 $PYENV 下未找到 requests/api.py，本项无法检查（请人工确认）"
else
    for API in $APIS; do
        # 该目录对应的 Python 版本，便于看清是查了哪个环境
        PYV=$(echo "$API" | sed -n 's#.*/lib/\(python3\.[0-9]*\)/.*#\1#p')
        [ -z "$PYV" ] && PYV="?"
        if grep -qE "$EVIL" "$API"; then
            bad "[$PYV] requests/api.py 含 bt.sb/bash.moe —— 后门已恢复！文件: $API"
            grep -nE "$EVIL" "$API" | sed 's/^/      /'
        elif grep -qE 'url\.find\(' "$API"; then
            bad "[$PYV] requests/api.py 出现 url.find( 改写逻辑 —— 疑似后门变种: $API"
            grep -n 'url.find(' "$API" | sed 's/^/      /'
        else
            good "[$PYV] requests/api.py 无劫持特征（$API）"
        fi
    done
fi

#-------------------------------------------------------------------------------
out "[2/7] pyenv 全目录是否含恶意域名"
#-------------------------------------------------------------------------------
if [ -d "$PYENV" ]; then
    HITS=$(grep -rlE "$EVIL" "$PYENV" 2>/dev/null | head -20)
    if [ -n "$HITS" ]; then
        bad "pyenv 中发现恶意域名:"
        echo "$HITS" | sed 's/^/      /'
    else
        good "pyenv 干净（0 个文件命中）"
    fi
fi

#-------------------------------------------------------------------------------
out "[3/7] 面板代码是否有新的 URL 重写型注入"
#-------------------------------------------------------------------------------
# 后门特征: 上一行做 url.find("<关键词>") 字符串判断，紧接着把 url 赋成硬编码 http(s) 地址
PY=""
[ -x "$PYENV/bin/python3" ] && PY="$PYENV/bin/python3"
[ -z "$PY" ] && command -v python3 >/dev/null && PY=python3
if [ -n "$PY" ]; then
    SUS=$("$PY" - "$PANEL" <<'PYEOF'
import os, re, sys
root = sys.argv[1]
trig = re.compile(r'\.find\s*\(')
asg  = re.compile(r'^\s*(url|uri|link|href)\s*=\s*["\']https?://', re.I)
hits = []
for dp, dns, fns in os.walk(root):
    if 'pyenv' in dp.split(os.sep) or '__pycache__' in dp:
        continue
    for fn in fns:
        if not fn.endswith('.py'):
            continue
        p = os.path.join(dp, fn)
        try:
            lines = open(p, encoding='utf-8', errors='ignore').read().splitlines()
        except Exception:
            continue
        for i, ln in enumerate(lines):
            if ln.lstrip().startswith('#') or not trig.search(ln):
                continue
            for nxt in lines[i+1:i+3]:
                if asg.match(nxt):
                    hits.append('%s:%d: %s\n      ==> %s' % (p, i+1, ln.strip(), nxt.strip()))
                    break
print('\n'.join(hits[:20]))
PYEOF
)
    if [ -n "$SUS" ]; then
        bad "发现后门型 URL 重写（判断行 + 硬编码 URL 赋值）:"
        echo "$SUS" | sed 's/^/      /'
    else
        good "未发现后门型 URL 重写"
    fi
else
    warn "缺少 python3，跳过该项检查"
fi

#-------------------------------------------------------------------------------
out "[4/7] 面板安装脚本 / 配置是否指向非官方源"
#-------------------------------------------------------------------------------
SUS2=$(grep -rlE "$EVIL" "$PANEL/install" "$PANEL/data" 2>/dev/null | head -10)
if [ -n "$SUS2" ]; then
    bad "面板 install/ 或 data/ 中发现恶意域名:"
    echo "$SUS2" | sed 's/^/      /'
    grep -rnE "$EVIL" $SUS2 2>/dev/null | sed 's/^/      /'
else
    good "install/ 与 data/ 无恶意域名"
fi

# public.sh 是否为自持版（面板 install/*.sh 里校验 public.sh 的守卫 md5 已被
# bt_selfhost_patch.sh 改成自持版的值；若客户端 public.sh 还是官方版，装软件时
# 这些脚本就会去外站重下 public.sh 覆盖回去，把自持改道整个回退）
PIN="742857589a6e1678316387065a61cc09"
for f in "$PANEL/install/public.sh"; do
    if [ -f "$f" ]; then
        M=$(md5sum "$f" 2>/dev/null | awk '{print $1}')
        if [ "$M" = "$PIN" ]; then
            good "public.sh 已是自持版（md5=$PIN，与 install/*.sh 守卫一致，不会去外站下载）"
        else
            warn "public.sh md5=$M ≠ 自持版 $PIN —— 客户端 public.sh 不是自持版，需重跑一次自持升级(update6.sh)还原"
        fi
    fi
done

# 扩展扫描面：plugin/ class/ script/ mod/ 是面板里最大、此前完全未被审计的代码面
# （旧版只扫 install/ 与 data/）。这些目录里确实可能有“合法提及”（注释、说明文案、
# 检测工具自身的正则），因此这里把“注释/文档中的提及”与“真正的代码引用”分开：
# 前者只提示不计失败，后者才判可疑，避免 daily cron 因假阳性天天报警而被无视。
EXDIRS=( "$PANEL/plugin" "$PANEL/class" "$PANEL/script" "$PANEL/mod" )
EXHITS=$(grep -rnIE "$EVIL" "${EXDIRS[@]}" 2>/dev/null | while IFS= read -r L; do
    F=${L%%:*}
    # 排除本脚本自身（按文件实体判断，注意不能按目录排除，否则若工具恰好装在
    # 被扫描目录内会整体漏检），以及自持镜像的几个工具文件名
    if [ "$F" = "$0" ] || { [ -e "$F" ] && [ -e "$0" ] && [ "$F" -ef "$0" ]; }; then
        continue
    fi
    case "${F##*/}" in
        check_btpanel.sh|bt_selfhost_patch.sh|install_plugin.sh|remysql_slave.sh) continue ;;
    esac
    printf '%s\n' "$L"
done)
if [ -z "$EXHITS" ]; then
    good "plugin/ class/ script/ mod/ 无恶意域名（0 个文件命中）"
else
    declare -a ECODE=() EDOC=()
    while IFS= read -r L; do
        [ -n "$L" ] || continue
        EF=${L%%:*}; ER=${L#*:}; EN=${ER%%:*}; EC=${ER#*:}
        ET=${EC#"${EC%%[![:space:]]*}"}       # 去掉行首空白
        EDOCFLAG=0
        case "$ET" in \#*|//*|\**|'<!--'*|';'*) EDOCFLAG=1 ;; esac
        case "$EF" in *.md|*.markdown|*.txt|*.rst|*.log) EDOCFLAG=1 ;; esac
        if [ $EDOCFLAG -eq 1 ]; then
            EDOC+=("$EF:$EN: $ET")
        else
            ECODE+=("$EF:$EN: $ET")
        fi
    done <<< "$EXHITS"
    if [ ${#ECODE[@]} -gt 0 ]; then
        bad "plugin/class/script/mod 中发现恶意域名的代码引用（非注释），疑似后门:"
        for x in "${ECODE[@]}"; do echo "      $x"; done
    else
        good "plugin/class/script/mod 的命中仅存在于注释/文档，未发现代码引用"
    fi
    if [ ${#EDOC[@]} -gt 0 ]; then
        out "  ⓘ 另有 ${#EDOC[@]} 处出现在注释/文档中的提及（仅信息，不计失败）:"
        for x in "${EDOC[@]}"; do out "      $x"; done
    fi
fi

#-------------------------------------------------------------------------------
out "[5/7] pip 源是否可信"
#-------------------------------------------------------------------------------
for f in /root/.pip/pip.conf "$PYENV/pip.conf"; do
    [ -f "$f" ] || continue
    IDX=$(grep -E '^\s*index-url' "$f" 2>/dev/null | head -1)
    case "$IDX" in
        *aliyun.com*|*tencent.com*|*tsinghua.edu.cn*|*pypi.org*) good "$f → $IDX" ;;
        *) bad "$f 的 pip 源可疑: $IDX" ;;
    esac
done

#-------------------------------------------------------------------------------
out "[6/7] LD_PRELOAD / ld.so.preload 是否被植入预加载库"
#-------------------------------------------------------------------------------
# LD_PRELOAD 是最经典的 rootkit/后门持久化手法之一，旧版 6 项检查完全没覆盖这条通道。
PRELOAD=/etc/ld.so.preload
# 已知合法的预加载组件：bt_security 插件的进程行为审计钩子（usranalyse）。
# 它属于预期内组件，必须判为“通过”，否则 cron 会天天误报。
KNOWN_PRELOAD="/usr/local/usranalyse/lib/libusranalyse.so"
KNOWN_PRELOAD_DIR="/usr/local/usranalyse/"
if [ -f "$PRELOAD" ]; then
    if [ ! -s "$PRELOAD" ]; then
        good "$PRELOAD 存在但为空（无全局预加载库）"
    else
        out "  $PRELOAD 非空（$(stat -c%s "$PRELOAD" 2>/dev/null) 字节），条目如下："
        while IFS= read -r LIB; do
            LIB="${LIB%%#*}"                                  # 去掉注释
            LIB=$(printf '%s' "$LIB" | tr -d ' \t\r')         # 去掉空白
            [ -n "$LIB" ] || continue
            out "    - $LIB"
            if [ ! -e "$LIB" ]; then
                bad "      预加载条目指向的文件不存在（失效条目），请人工确认: $LIB"
                continue
            fi
            if [ "$LIB" = "$KNOWN_PRELOAD" ] || [ "${LIB#$KNOWN_PRELOAD_DIR}" != "$LIB" ]; then
                good "      检测到 bt_security 的 usranalyse 钩子（预期内，非后门）: $LIB"
                continue
            fi
            MAGIC=$(head -c 4 "$LIB" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n')
            if [ "$MAGIC" != "7f454c46" ]; then
                bad "      该条目不是 ELF 共享库，可疑: $LIB"
                continue
            fi
            case "$LIB" in
                /lib/*|/lib64/*|/usr/lib/*|/usr/lib64/*|/usr/local/*/lib/*)
                    warn "      非已知的预加载库（位于常见库目录），请人工确认: $LIB" ;;
                *)
                    bad "      预加载库位于非常规目录，可疑: $LIB" ;;
            esac
        done <<< "$(cat "$PRELOAD" 2>/dev/null)"
    fi
else
    good "$PRELOAD 不存在（无全局预加载劫持）"
fi
# 当前进程的 LD_PRELOAD 环境变量（cron 下通常为空，仅作信息）
if [ -n "$LD_PRELOAD" ]; then
    warn "当前环境变量 LD_PRELOAD=$LD_PRELOAD"
else
    out "  ⓘ 当前环境变量 LD_PRELOAD 为空"
fi
# 附加库搜索路径配置：只列文件名清单，不展开内容
if [ -d /etc/ld.so.conf.d ]; then
    out "  ⓘ /etc/ld.so.conf.d/ 文件名清单: $(ls -1 /etc/ld.so.conf.d 2>/dev/null | tr '\n' ' ')"
fi

#-------------------------------------------------------------------------------
out "[7/7] 关键文件是否相对基线发生漂移"
#-------------------------------------------------------------------------------
FILES=(
    $APIS
    "$PANEL/install/public.sh"
    "$PANEL/install/nginx.sh"
    "$PANEL/install/mysql.sh"
    "$PANEL/install/php.sh"
    "$PANEL/class/system.py"
    "$PANEL/class/panelPlugin.py"
)
# 上面 $APIS 来自 [1/7]，已按 glob/find 解析出“实际存在”的 requests/api.py 路径。
# 旧版这里写死 python3.7，在 3.11/3.13 面板上会记录一个不存在的路径，本项静默失效。
if [ "${1:-}" = "--init-baseline" ] || [ "${2:-}" = "--init-baseline" ]; then
    : > "$BASELINE"
    BN=0
    for f in "${FILES[@]}"; do
        if [ -f "$f" ]; then
            sha256sum "$f" >> "$BASELINE"
            BN=$((BN+1))
        else
            warn "跳过（当前不存在）: $f"
        fi
    done
    good "已建立基线（$BN 个文件）: $BASELINE"
    # 回显写入的真实路径，便于确认没有写死 python3.7
    [ $QUIET -eq 0 ] && sed 's/^/    /' "$BASELINE"
elif [ ! -f "$BASELINE" ]; then
    warn "尚无基线，执行 'bash $0 --init-baseline' 可建立"
else
    DRIFT=0; GONE=0; STALE=0
    while read -r h f; do
        [ -n "$f" ] || continue
        if [ ! -e "$f" ]; then
            # 区分“文件消失”与“内容漂移”：Python 版本目录变化会走到这里
            warn "文件消失: $f（基线中有记录，当前不存在；若为 Python 版本目录变化，建议重建基线）"
            GONE=$((GONE+1)) ; continue
        fi
        n=$(sha256sum "$f" | awk '{print $1}')
        if [ "$n" != "$h" ]; then
            bad "内容漂移: $f（基线 ${h:0:12}… → 当前 ${n:0:12}…）"
            DRIFT=1
        fi
    done < "$BASELINE"
    # 当前关键文件是否都已被基线覆盖；老基线（例如记的是 python3.7 那条）会缺项
    for f in "${FILES[@]}"; do
        [ -f "$f" ] || continue
        if ! awk -v p="$f" '$2==p{f=1} END{exit !f}' "$BASELINE"; then
            warn "基线缺该项，建议重建基线: $f"
            STALE=1
        fi
    done
    [ $DRIFT -eq 0 ] && good "关键文件与基线一致（无内容漂移）"
    [ $GONE -gt 0 ] && warn "有 $GONE 个基线条目对应的文件已消失（详见上方），建议重建基线"
    [ $STALE -eq 1 ] && warn "当前基线未覆盖全部关键文件（老基线），请执行 'bash $0 --init-baseline' 重建"
fi

#-------------------------------------------------------------------------------
# 干净时 --quiet 必须“零输出”（cron 巡检依赖此行为），故空行也要静音
[ $QUIET -eq 0 ] && echo
if [ $FAIL -eq 0 ]; then
    out "${GRN}===== 自检通过：未发现已知后门特征 =====${NC}"
    exit 0
else
    echo "${RED}===== 自检失败：发现可疑项，请立即人工检查 =====${NC}"
    exit 1
fi
