Files

336 lines
10 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.
#!/usr/bin/env bash
# 受控自动修复:默认只生成计划,不修改远端。
# 只使用现有 SSH key;不接受密码参数,不关闭 host key 校验。
set -Eeuo pipefail
usage() {
cat <<'EOF'
用法:
remediate.sh user@host --plan [--fix ssh-perms] [--fix fail2ban]
remediate.sh user@host --apply --fix ssh-perms --confirm "APPLY user@host"
remediate.sh user@host --apply --fix fail2ban --ignore-ip CIDR --confirm "APPLY user@host"
remediate.sh user@host --rollback BACKUP_ID --confirm "ROLLBACK BACKUP_ID"
默认是 --plan,只读检查。
可用修复项:
ssh-perms 修正当前 SSH 用户 ~/.ssh 和 authorized_keys 权限
fail2ban 写入独立 sshd jail,要求提供至少一个 --ignore-ip
防火墙、SSH 登录策略、容器绑定、恶意文件和密钥/token 不由本脚本自动修改。
EOF
}
die() { echo "错误: $*" >&2; exit 2; }
[[ $# -ge 1 ]] || { usage; exit 2; }
case "$1" in
-h|--help) usage; exit 0 ;;
esac
HOST=$1
shift
[[ "$HOST" != -* && "$HOST" != *[[:space:]]* ]] || die "HOST 必须是 user@host 或 SSH config 别名"
MODE=plan
ROLLBACK_ID=
CONFIRM=
SSH_PORT=
FIXES=()
IGNORE_IPS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--plan)
[[ "$MODE" == plan ]] || die "--plan 不能和 --apply/--rollback 同时使用"
MODE=plan; shift
;;
--apply)
[[ "$MODE" == plan ]] || die "--apply 不能和 --rollback 同时使用"
MODE=apply; shift
;;
--rollback)
[[ $# -ge 2 ]] || die "--rollback 缺少 BACKUP_ID"
[[ "$MODE" == plan ]] || die "--rollback 不能和 --apply 同时使用"
MODE=rollback; ROLLBACK_ID=$2; shift 2
;;
--fix)
[[ $# -ge 2 ]] || die "--fix 缺少修复项"
case "$2" in
ssh-perms|fail2ban) FIXES+=("$2") ;;
*) die "不支持的修复项: $2" ;;
esac
shift 2
;;
--ignore-ip)
[[ $# -ge 2 ]] || die "--ignore-ip 缺少 CIDR/IP"
[[ "$2" =~ ^[0-9A-Fa-f:.]+(/[0-9]{1,3})?$ ]] || die "非法 IP/CIDR: $2"
IGNORE_IPS+=("$2"); shift 2
;;
--ssh-port)
[[ $# -ge 2 ]] || die "--ssh-port 缺少端口"
[[ "$2" =~ ^[0-9]+$ && "$2" -ge 1 && "$2" -le 65535 ]] || die "非法 SSH 端口: $2"
SSH_PORT=$2; shift 2
;;
--confirm)
[[ $# -ge 2 ]] || die "--confirm 缺少确认串"
CONFIRM=$2; shift 2
;;
-h|--help)
usage; exit 0
;;
*) die "未知参数: $1" ;;
esac
done
if [[ "$MODE" == apply ]]; then
((${#FIXES[@]} > 0)) || die "--apply 必须至少指定一个 --fix"
[[ "$CONFIRM" == "APPLY $HOST" ]] || die "需要确认串: APPLY $HOST"
if printf '%s\n' "${FIXES[@]}" | grep -qx fail2ban && ((${#IGNORE_IPS[@]} == 0)); then
die "应用 fail2ban 前必须至少提供一个 --ignore-ip,避免误封管理入口"
fi
elif [[ "$MODE" == rollback ]]; then
[[ "$ROLLBACK_ID" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || die "非法 BACKUP_ID"
[[ "$CONFIRM" == "ROLLBACK $ROLLBACK_ID" ]] || die "需要确认串: ROLLBACK $ROLLBACK_ID"
fi
FIX_CSV=$(IFS=,; echo "${FIXES[*]-}")
IGNORE_CSV=$(IFS=,; echo "${IGNORE_IPS[*]-}")
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
"${SSH[@]}" bash -s -- "$MODE" "$FIX_CSV" "$IGNORE_CSV" "$SSH_PORT" "$ROLLBACK_ID" <<'REMOTE'
set -Eeuo pipefail
MODE=${1:?}
FIX_CSV=${2-}
IGNORE_CSV=${3-}
REQUESTED_PORT=${4-}
ROLLBACK_ID=${5-}
has_fix() {
case ",$FIX_CSV," in
*",$1,"*) return 0 ;;
*) return 1 ;;
esac
}
as_root() {
if [[ "$(id -u)" == 0 ]]; then
"$@"
else
sudo -n "$@"
fi
}
need_root() {
as_root true
}
show_state() {
echo "目标: $(id -un)@$(hostname -f 2>/dev/null || hostname)"
echo "系统: $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")"
echo "SSH 生效配置:"
if command -v sshd >/dev/null 2>&1; then
sshd -T 2>/dev/null | grep -iE '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries) ' || true
else
echo " sshd: unavailable"
fi
echo "防火墙状态(只读):"
ufw status verbose 2>/dev/null || true
firewall-cmd --state 2>/dev/null || true
nft list ruleset 2>/dev/null | head -80 || true
echo "Fail2ban:"
if command -v fail2ban-client >/dev/null 2>&1; then
fail2ban-client status 2>/dev/null || true
else
echo " fail2ban-client: unavailable"
fi
echo "容器暴露(只读):"
docker ps -a --format '{{.Names}} ports={{.Ports}}' 2>/dev/null || true
}
find_ssh_port() {
if [[ -n "$REQUESTED_PORT" ]]; then
printf '%s\n' "$REQUESTED_PORT"
else
sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }' || true
fi
}
print_plan() {
show_state
echo
echo "修复计划(默认不执行):"
if has_fix ssh-perms; then
current_user=$(id -un)
home=$(getent passwd "$current_user" | awk -F: '{print $6}')
echo "- ssh-perms: 检查并修正 $home/.ssh=0700、$home/.ssh/authorized_keys=0600"
fi
if has_fix fail2ban; then
port=$(find_ssh_port)
echo "- fail2ban: 写入 /etc/fail2ban/jail.d/host-sec-check.local,SSH 端口=${port:-未识别}"
if [[ -n "$IGNORE_CSV" ]]; then
echo " ignoreip: $IGNORE_CSV"
else
echo " 阻断: --apply 前必须提供 --ignore-ip"
fi
fi
echo "- firewall/SSH 策略/Docker 绑定/恶意文件: 只报告,不由脚本自动修改"
}
backup_root=
manifest=
modes=
record_manifest() {
local state=$1 path=$2
as_root sh -c 'printf "%s %s\\n" "$1" "$2" >> "$3"' _ "$state" "$path" "$manifest"
}
record_mode() {
local mode path=$1
mode=$(stat -c '%a' "$path")
as_root sh -c 'printf "%s %s\\n" "$1" "$2" >> "$3"' _ "$mode" "$path" "$modes"
}
backup_file() {
local path=$1 dest
dest="$backup_root$path"
if [[ -e "$path" || -L "$path" ]]; then
as_root install -d -m 0700 "$(dirname "$dest")"
as_root cp -a -- "$path" "$dest"
record_manifest present "$path"
else
record_manifest absent "$path"
fi
}
start_backup() {
local id
id=$(date -u +%Y%m%dT%H%M%SZ)
backup_root="/var/backups/host-sec-check/$id"
manifest="$backup_root/MANIFEST"
modes="$backup_root/MODES"
as_root install -d -m 0700 /var/backups/host-sec-check
as_root mkdir -m 0700 "$backup_root"
as_root install -m 0600 /dev/null "$manifest"
as_root install -m 0600 /dev/null "$modes"
echo "备份: $backup_root"
}
apply_ssh_perms() {
local user home ssh_dir auth
user=$(id -un)
home=$(getent passwd "$user" | awk -F: '{print $6}')
[[ -n "$home" && "$home" != / ]] || { echo "[跳过] 无法识别当前用户 home"; return 1; }
ssh_dir="$home/.ssh"
auth="$ssh_dir/authorized_keys"
if [[ -d "$ssh_dir" ]]; then
record_mode "$ssh_dir"
as_root chmod 700 "$ssh_dir"
echo "[已修复] $ssh_dir mode=0700"
else
echo "[未修改] $ssh_dir 不存在"
fi
if [[ -f "$auth" ]]; then
record_mode "$auth"
as_root chmod 600 "$auth"
echo "[已修复] $auth mode=0600"
else
echo "[未修改] $auth 不存在"
fi
}
apply_fail2ban() {
local port ignore_line path=/etc/fail2ban/jail.d/host-sec-check.local
command -v fail2ban-client >/dev/null 2>&1 || { echo "[阻断] 未安装 fail2ban-client;不自动安装系统包" >&2; return 1; }
port=$(find_ssh_port)
[[ "$port" =~ ^[0-9]+$ ]] || { echo "[阻断] 无法识别 SSH 端口" >&2; return 1; }
as_root systemctl is-active --quiet fail2ban || { echo "[阻断] fail2ban 未运行;不自动启动服务" >&2; return 1; }
ignore_line="127.0.0.1/8 ::1"
IFS=, read -r -a extra_ips <<< "$IGNORE_CSV"
for ip in "${extra_ips[@]}"; do
[[ -n "$ip" ]] && ignore_line+=" $ip"
done
backup_file "$path"
as_root install -d -m 0755 /etc/fail2ban/jail.d
as_root tee "$path" >/dev/null <<EOF
# Managed by host-sec-check. Backup: $backup_root
[sshd]
enabled = true
backend = systemd
filter = sshd
port = $port
ignoreip = $ignore_line
maxretry = 5
findtime = 10m
bantime = 10m
action = %(action_)s
EOF
as_root fail2ban-client -t
as_root fail2ban-client reload
as_root fail2ban-client status sshd
echo "[已修复] $path"
}
rollback() {
backup_root="/var/backups/host-sec-check/$ROLLBACK_ID"
manifest="$backup_root/MANIFEST"
modes="$backup_root/MODES"
as_root test -f "$manifest" || { echo "[阻断] 找不到备份: $backup_root" >&2; return 1; }
while read -r state path; do
[[ -n "${path:-}" ]] || continue
if [[ ! "$path" =~ ^/root/\.ssh(/authorized_keys)?$ && ! "$path" =~ ^/home/[A-Za-z0-9._-]+/\.ssh(/authorized_keys)?$ && "$path" != /etc/fail2ban/jail.d/host-sec-check.local ]]; then
echo "[阻断] 备份路径不在允许回滚范围: $path" >&2
return 1
fi
case "$state" in
present)
source="$backup_root$path"
[[ -e "$source" || -L "$source" ]] || { echo "[阻断] 备份文件缺失: $source" >&2; return 1; }
as_root install -d -m 0755 "$(dirname "$path")"
as_root cp -a -- "$source" "$path"
echo "[已回滚] $path"
;;
absent)
as_root rm -f -- "$path"
echo "[已回滚] 删除本次新建文件 $path"
;;
*) echo "[阻断] MANIFEST 状态无效: $state" >&2; return 1 ;;
esac
done < <(as_root cat "$manifest")
if as_root test -f "$modes"; then
while read -r mode path; do
[[ -n "${path:-}" && -e "$path" ]] || continue
if [[ ! "$path" =~ ^/root/\.ssh(/authorized_keys)?$ && ! "$path" =~ ^/home/[A-Za-z0-9._-]+/\.ssh(/authorized_keys)?$ ]]; then
echo "[阻断] 权限备份路径不在允许回滚范围: $path" >&2
return 1
fi
as_root chmod "$mode" "$path"
echo "[已回滚] $path mode=$mode"
done < <(as_root cat "$modes")
fi
}
case "$MODE" in
plan)
print_plan
;;
apply)
need_root
start_backup
trap 'rc=$?; echo "[部分失败] 已执行的变更可能不完整: $backup_root" >&2; echo "回滚命令: remediate.sh HOST --rollback $(basename "$backup_root") --confirm \"ROLLBACK $(basename "$backup_root")\"" >&2; exit "$rc"' ERR
if has_fix ssh-perms; then apply_ssh_perms; fi
if has_fix fail2ban; then apply_fail2ban; fi
trap - ERR
echo "应用完成。备份目录: $backup_root"
;;
rollback)
need_root
rollback
;;
*)
echo "[阻断] 未知模式: $MODE" >&2
exit 2
;;
esac
REMOTE