Files
.dotfile/script/server-security-check.sh
laily 8c80e5b630 fix: 修正 server-security-check 的 SSH 误报并标注高风险
- PermitRootLogin: 接受 without-password 别名(OpenSSH -T 会输出该旧别名),
  避免把仅允许密钥的 root 登录误报为“无法确定安全状态”
- 检查未通过时列出配置文件中的相关设置行:sshd 只采用先读到的一条,
  主配置 Include 之前的 drop-in 以及 Match 块内的设置都会造成“改过但不生效”
- sshd -T 读取失败时同样给出配置文件设置,便于定位
- 参数匹配改为大小写不敏感
- 高风险整行红色、警告黄色,汇总同步着色;非 TTY 或 NO_COLOR 时禁用
2026-09-17 12:08:24 +08:00

445 lines
15 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 快速检查服务器 SSH、端口、防火墙、特权账号与近期登录风险
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common/help.sh"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common/os-arch.sh"
usage() {
cat <<'EOF'
用法: server-security-check [--no-sudo]
快速、只读地检查以下服务器安全项:
- SSH 是否允许 root 或密码登录,以及生效监听端口
- 当前监听端口,以及常见明文服务端口
- 本机防火墙是否启用
- UID 0 异常账号和 sudoers 中非 root 主体的免密 sudo 规则
- 最近 24 小时 SSH 失败登录摘要和最近成功登录来源
选项:
-n, --no-sudo 不请求 sudo;以当前权限执行受限的只读检查
-h, --help 显示此帮助
说明:
不扫描网络、不查询漏洞库、不修改配置,通常可在数秒内完成。
默认会请求 sudo,以读取 sudoers 和系统认证日志;可直接通过 ff s 运行:
ff s server-security-check
若只想执行无需 sudo 的检查:
ff s server-security-check --no-sudo
EOF
}
NO_SUDO=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
dotfile_show_help
;;
-n|--no-sudo)
NO_SUDO=1
shift
;;
*)
echo "错误: 不支持参数: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ $NO_SUDO -eq 0 && $EUID -ne 0 ]]; then
command -v sudo >/dev/null 2>&1 || {
echo "错误: 未找到 sudo;请使用 root 运行,或添加 --no-sudo 执行受限检查" >&2
exit 1
}
echo "需要 sudo 权限以执行完整检查;请输入当前用户的密码。"
exec sudo -- "$0"
fi
# 仅在输出到终端时着色;重定向到文件或设置 NO_COLOR 时保持纯文本
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
COLOR_WARN=$'\033[1;33m'
COLOR_HIGH=$'\033[0;31m'
COLOR_RESET=$'\033[0m'
else
COLOR_WARN=""
COLOR_HIGH=""
COLOR_RESET=""
fi
PASS_COUNT=0
WARN_COUNT=0
HIGH_COUNT=0
INFO_COUNT=0
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf '[通过] %s\n' "$*"
}
warn() {
WARN_COUNT=$((WARN_COUNT + 1))
printf '%s[警告] %s%s\n' "$COLOR_WARN" "$*" "$COLOR_RESET"
}
high() {
HIGH_COUNT=$((HIGH_COUNT + 1))
printf '%s[高风险] %s%s\n' "$COLOR_HIGH" "$*" "$COLOR_RESET"
}
info() {
INFO_COUNT=$((INFO_COUNT + 1))
printf '[信息] %s\n' "$*"
}
section() {
printf '\n== %s ==\n' "$1"
}
find_sshd() {
local candidate
if command -v sshd >/dev/null 2>&1; then
command -v sshd
return
fi
for candidate in /usr/sbin/sshd /usr/local/sbin/sshd; do
if [[ -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return
fi
done
}
check_sshd_setting() {
local setting value
setting=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
value=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk -v key="$setting" 'tolower($1) == key { print tolower($2); exit }')
printf '%s' "$value"
}
SSHD_CONFIG_FILE=/etc/ssh/sshd_config
SSHD_CONFIG_DIR=/etc/ssh/sshd_config.d
# 列出配置文件中显式设置某个参数的行,用于解释“明明改过了为何还不生效”。
# sshd 对同一参数只采用先读到的一条:Include /etc/ssh/sshd_config.d/*.conf
# 通常位于主配置前部,因此 drop-in 会覆盖主配置后部的同名设置;Match 块内的设置
# 也不作用于全局配置。
check_ssh_setting_sources() {
local key file matches files
for key in "$@"; do
files=()
[[ -r "$SSHD_CONFIG_FILE" ]] && files+=("$SSHD_CONFIG_FILE")
if [[ -d "$SSHD_CONFIG_DIR" ]]; then
for file in "$SSHD_CONFIG_DIR"/*.conf; do
[[ -r "$file" ]] && files+=("$file")
done
fi
((${#files[@]} > 0)) || continue
matches=$(grep -inHE "^[[:space:]]*${key}([[:space:]]|=)" "${files[@]}" 2>/dev/null || true)
if [[ -z "$matches" ]]; then
info "${key} 未在配置文件中显式设置,生效值可能来自 sshd 默认值"
continue
fi
info "${key} 的配置行(sshd 只采用先读到的一条;Match 块内的设置不作用于全局):"
printf '%s\n' "$matches" | sed 's/^/ /'
done
}
check_ssh() {
local sshd_bin root_login password_login ssh_ports
section "SSH 登录"
sshd_bin=$(find_sshd)
if [[ -z "$sshd_bin" ]]; then
info "未安装 sshd,跳过 SSH 配置检查"
return
fi
SSHD_EFFECTIVE_CONFIG=$("$sshd_bin" -T 2>/dev/null)
if [[ -z "$SSHD_EFFECTIVE_CONFIG" ]]; then
warn "无法读取 sshd 生效配置;请使用 sudo 重试或检查 sshd 配置"
check_ssh_setting_sources permitrootlogin passwordauthentication
return
fi
root_login=$(check_sshd_setting permitrootlogin)
password_login=$(check_sshd_setting passwordauthentication)
ssh_ports=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk '$1 == "port" {print $2}' | sort -nu | paste -sd, -)
case "$root_login" in
no|forced-commands-only)
pass "SSH 已禁止 root 交互式登录(PermitRootLogin: $root_login)"
;;
prohibit-password|without-password)
# without-password 是 prohibit-password 的旧别名,含义相同
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: $root_login)"
;;
yes)
high "SSH 允许 root 直接登录;建议设为 PermitRootLogin no"
check_ssh_setting_sources permitrootlogin
;;
*)
warn "无法确定 PermitRootLogin 的安全状态(当前: ${root_login:-未设置})"
check_ssh_setting_sources permitrootlogin
;;
esac
case "$password_login" in
no)
pass "SSH 已禁用密码登录"
;;
yes)
warn "SSH 允许密码登录;公网服务器建议改用密钥认证"
check_ssh_setting_sources passwordauthentication
;;
*)
warn "无法确定 PasswordAuthentication 的安全状态(当前: ${password_login:-未设置})"
check_ssh_setting_sources passwordauthentication
;;
esac
if [[ -z "$ssh_ports" ]]; then
warn "无法确定 SSH 生效监听端口"
elif [[ ",$ssh_ports," == *,22,* ]]; then
warn "SSH 生效监听端口: $ssh_ports(仍包含默认端口 22;请确认是否有意保留)"
else
pass "SSH 生效监听端口: $ssh_ports"
fi
}
check_listening_ports() {
local listeners risky
section "监听端口"
if [[ "$OS_NAME" == "linux" ]] && command -v ss >/dev/null 2>&1; then
listeners=$(ss -H -lntu 2>/dev/null)
elif [[ "$OS_NAME" == "darwin" ]] && command -v lsof >/dev/null 2>&1; then
listeners=$(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | sed 1d)
elif command -v netstat >/dev/null 2>&1; then
listeners=$(netstat -lnt 2>/dev/null || netstat -an 2>/dev/null)
else
warn "找不到 ss、lsof 或 netstat,无法列出监听端口"
return
fi
if [[ -z "$listeners" ]]; then
pass "未发现 TCP/UDP 监听端口"
return
fi
printf '%s\n' "$listeners" | sed -n '1,30p'
local listener_count
listener_count=$(printf '%s\n' "$listeners" | wc -l | tr -d ' ')
if ((listener_count > 30)); then
info "仅显示前 30 个监听项,共 ${listener_count} 项"
fi
risky=$(printf '%s\n' "$listeners" | grep -E '(:21|:23|:69|:111|:512|:513|:514)([[:space:]]|$)' || true)
if [[ -n "$risky" ]]; then
warn "发现 FTP/Telnet/rsh 等常见高风险或明文服务端口:$(printf '%s' "$risky" | tr '\n' ';')"
else
pass "未发现常见明文服务端口(21、23、69、111、512-514)"
fi
}
check_firewall() {
local ufw_output zones
section "防火墙"
case "$OS_NAME" in
linux)
if command -v ufw >/dev/null 2>&1; then
ufw_output=$(ufw status verbose 2>&1)
if printf '%s\n' "$ufw_output" | grep -qi '^Status: active'; then
if printf '%s\n' "$ufw_output" | grep -qi 'Default: deny (incoming)'; then
pass "UFW 已启用,默认拒绝入站连接"
else
warn "UFW 已启用,但未确认默认拒绝入站连接"
fi
else
warn "UFW 未启用;若未使用云安全组或其他防火墙,请启用入站访问控制"
fi
return
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>/dev/null | grep -qx running; then
zones=$(firewall-cmd --get-active-zones 2>/dev/null | tr '\n' ';')
pass "firewalld 正在运行(活动区域: ${zones:-未知})"
info "请确认活动区域只放行实际需要的服务"
return
fi
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nftables 2>/dev/null; then
pass "nftables 服务正在运行"
info "请确认 nftables 的入站默认策略为 drop 或 reject"
return
fi
warn "未检测到正在运行的 UFW、firewalld 或 nftables;若依赖云安全组,请确认其已限制入站端口"
;;
darwin)
if [[ -x /usr/libexec/ApplicationFirewall/socketfilterfw ]]; then
if /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null | grep -qi enabled; then
pass "macOS 应用防火墙已启用"
else
warn "macOS 应用防火墙未启用"
fi
else
warn "无法读取 macOS 防火墙状态"
fi
;;
*)
warn "不支持的系统,无法检测防火墙"
;;
esac
}
add_sudoers_file() {
local file="$1" existing
[[ -r "$file" ]] || return
for existing in "${SUDOERS_FILES[@]}"; do
[[ "$existing" == "$file" ]] && return
done
SUDOERS_FILES+=("$file")
}
collect_sudoers_files() {
local file
SUDOERS_FILES=()
# visudo 会按照 sudo 实际的 #include / #includedir 关系解析配置,并逐个报告已检查的文件。
if command -v visudo >/dev/null 2>&1; then
while IFS= read -r file; do
add_sudoers_file "$file"
done < <(visudo -c 2>&1 | awk '/: parsed OK$/ {sub(/: parsed OK$/, ""); print}')
fi
# 若 visudo 不可用或配置本身有语法错误,仍尽力检查标准位置。
add_sudoers_file /etc/sudoers
if [[ -d /etc/sudoers.d ]]; then
for file in /etc/sudoers.d/*; do
add_sudoers_file "$file"
done
fi
}
check_privileged_accounts() {
local uid_zero sudo_rules
section "高权限账号"
uid_zero=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd 2>/dev/null)
if [[ -n "$uid_zero" ]]; then
high "发现非 root 的 UID 0 账号:$(printf '%s' "$uid_zero" | tr '\n' ' ')"
else
pass "未发现非 root 的 UID 0 账号"
fi
if [[ $EUID -ne 0 ]]; then
info "未以 root 运行,跳过完整 sudoers 免密规则检查;省略 --no-sudo 可自动请求 sudo 后重跑"
return
fi
collect_sudoers_files
if ((${#SUDOERS_FILES[@]} == 0)); then
warn "未找到可读取的 sudoers 配置文件"
return
fi
sudo_rules=$(awk '
/^[[:space:]]*[^#].*NOPASSWD/ {
rule = $0
sub(/^[[:space:]]*/, "", rule)
# root 已拥有完整权限;仅 root 作为规则主体时不构成额外提权路径。
# 含其他用户、组、别名或 ALL 的规则仍保留告警。
if (rule ~ /^root[[:space:]]+/) next
print FILENAME ":" $0
}
' "${SUDOERS_FILES[@]}" 2>/dev/null || true)
if [[ -n "$sudo_rules" ]]; then
warn "发现非 root 主体的免密 sudo 规则(含文件路径):$(printf '%s' "$sudo_rules" | tr '\n' ';')"
else
pass "未发现非 root 主体的 NOPASSWD 免密 sudo 规则"
fi
}
check_recent_logins() {
local auth_log log_text failed_count successful
section "近期 SSH 登录"
auth_log=""
for candidate in /var/log/auth.log /var/log/secure; do
if [[ -r "$candidate" ]]; then
auth_log="$candidate"
break
fi
done
if [[ -n "$auth_log" ]]; then
log_text=$(tail -n 5000 "$auth_log" 2>/dev/null)
elif command -v journalctl >/dev/null 2>&1; then
log_text=$(journalctl --no-pager --since '24 hours ago' -u ssh -u sshd 2>/dev/null)
else
log_text=""
fi
if [[ -n "$log_text" ]]; then
failed_count=$(printf '%s\n' "$log_text" | grep -Eic 'Failed password|Invalid user|authentication failure|Failed publickey' || true)
if ((failed_count == 0)); then
pass "最近 24 小时未发现 SSH 失败登录记录"
elif ((failed_count < 20)); then
warn "最近 24 小时发现 ${failed_count} 条 SSH 失败登录记录"
else
high "最近 24 小时发现 ${failed_count} 条 SSH 失败登录记录,可能存在暴力破解"
fi
else
info "无法读取最近 24 小时 SSH 日志;省略 --no-sudo 可自动请求 sudo 后重跑,或确认日志服务正常"
fi
if command -v last >/dev/null 2>&1; then
successful=$(last -i -n 5 2>/dev/null | sed -n '/^wtmp begins/q;p' | sed '/^$/d')
if [[ -n "$successful" ]]; then
printf '最近成功登录(最多 5 条):\n%s\n' "$successful"
else
info "未读取到成功登录记录"
fi
fi
}
OS_NAME=$(get_os)
echo "服务器快速安全检查(系统: ${OS_NAME}/$(get_arch))"
if [[ $EUID -eq 0 ]]; then
echo "只读检查;已使用 sudo 权限执行完整检查。"
else
echo "只读检查;已按 --no-sudo 以当前权限执行受限检查。"
fi
check_ssh
check_listening_ports
check_firewall
check_privileged_accounts
check_recent_logins
printf '\n== 汇总 ==\n'
if ((HIGH_COUNT > 0)); then
SUMMARY_COLOR="$COLOR_HIGH"
SUMMARY_ADVICE='请优先处理“高风险”项。'
elif ((WARN_COUNT > 0)); then
SUMMARY_COLOR="$COLOR_WARN"
SUMMARY_ADVICE='建议逐项确认“警告”项是否符合服务器的实际部署方式。'
else
SUMMARY_COLOR=""
SUMMARY_ADVICE='未发现需要立即处理的项。'
fi
printf '%s通过: %d 警告: %d 高风险: %d 信息: %d%s\n' \
"$SUMMARY_COLOR" "$PASS_COUNT" "$WARN_COUNT" "$HIGH_COUNT" "$INFO_COUNT" "$COLOR_RESET"
printf '%s%s%s\n' "$SUMMARY_COLOR" "$SUMMARY_ADVICE" "$COLOR_RESET"