feat(host-sec-check): add staged hardening and remediation
This commit is contained in:
@@ -35,8 +35,30 @@ echo "----- users uid0 / human -----"
|
||||
awk -F: '$3==0 || ($3>=1000 && $3<65534) {print}' /etc/passwd
|
||||
echo "----- sshd -----"
|
||||
grep -E "^(Port|PermitRootLogin|PasswordAuthentication|PubkeyAuthentication)" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/* 2>/dev/null
|
||||
sshd -t 2>&1 && echo "sshd_syntax=ok" || echo "sshd_syntax=failed"
|
||||
sshd -T 2>/dev/null | grep -iE "^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitempty passwords|maxauthtries|x11forwarding|allowtcpforwarding) "
|
||||
systemctl is-active ssh.service sshd.service ssh.socket 2>/dev/null
|
||||
systemctl is-enabled ssh.service sshd.service ssh.socket 2>/dev/null
|
||||
echo "----- last -----"
|
||||
last -a | head -15
|
||||
echo "----- failed logins -----"
|
||||
lastb -a 2>/dev/null | head -15 || true
|
||||
echo "----- firewall -----"
|
||||
ufw status verbose 2>/dev/null || true
|
||||
firewall-cmd --state 2>/dev/null || true
|
||||
nft list ruleset 2>/dev/null | head -160 || true
|
||||
iptables -S 2>/dev/null | head -160 || true
|
||||
echo "----- fail2ban -----"
|
||||
fail2ban-client status 2>/dev/null || echo "fail2ban unavailable"
|
||||
echo "----- timers -----"
|
||||
systemctl list-timers --all --no-pager 2>/dev/null | head -120
|
||||
echo "----- privilege groups -----"
|
||||
getent group sudo wheel docker lxd libvirt 2>/dev/null || true
|
||||
echo "----- outbound connections (numeric) -----"
|
||||
ss -tpn 2>/dev/null | head -160 || true
|
||||
echo "----- docker security summary -----"
|
||||
docker ps -a --format '{{.Names}} ports={{.Ports}}' 2>/dev/null || true
|
||||
docker inspect --format '{{.Name}} privileged={{.HostConfig.Privileged}} pid={{.HostConfig.PidMode}} network={{.HostConfig.NetworkMode}} binds={{len .Mounts}}' $(docker ps -aq 2>/dev/null) 2>/dev/null || true
|
||||
EOS
|
||||
}
|
||||
|
||||
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user