From cf78d0cab26d9eb17aa30dfc4bb6fe84e99f333e Mon Sep 17 00:00:00 2001 From: laily Date: Sat, 10 Oct 2026 18:59:38 +0800 Subject: [PATCH] feat(host-sec-check): add staged hardening and remediation --- skills/host-sec-check/README.md | 9 +- skills/host-sec-check/SKILL.md | 49 ++- skills/host-sec-check/references/checklist.md | 13 +- skills/host-sec-check/references/hardening.md | 73 ++++ .../host-sec-check/references/remediation.md | 53 +++ skills/host-sec-check/scripts/collect.sh | 22 ++ skills/host-sec-check/scripts/remediate.sh | 335 ++++++++++++++++++ 7 files changed, 545 insertions(+), 9 deletions(-) create mode 100644 skills/host-sec-check/references/hardening.md create mode 100644 skills/host-sec-check/references/remediation.md create mode 100755 skills/host-sec-check/scripts/remediate.sh diff --git a/skills/host-sec-check/README.md b/skills/host-sec-check/README.md index 74dd52a..c7bfd40 100644 --- a/skills/host-sec-check/README.md +++ b/skills/host-sec-check/README.md @@ -1,12 +1,13 @@ # host-sec-check -对一台 Linux/VPS 做入侵取向的安全检查:先取证,再给你一份结论和处置清单。默认不改机器。 +对一台 Linux/VPS 做入侵取向的安全检查,并支持用户确认后的分阶段安全加固:先取证,再给你一份结论和处置清单。默认不改机器。 ## 什么时候用 - 「这台机做一次安全检查」 - 「好像被黑了 / 有矿机 / 服务无故 OOM」 - 「看看哪些端口对公网开着」 +- 「给新 VPS 做安全加固 / 配置 SSH、防火墙、Fail2ban」 - 检查另一台机器时,直接点名主机:`对 vvps4 跑 /host-sec-check` 适用于单台 Linux(systemd + 常见 Docker/k3s)。不是应用代码审计,也不是云账号合规审计。 @@ -34,7 +35,11 @@ 2. 怀疑进程隐藏时,用容器读宿主机,避免被矿机库骗过。 3. 从另一台机器探测公网端口(含 IPv6,如果有)。 4. 给出结论:是否沦陷、入口、对外端口、建议处置。 -5. 你点头后才停容器、占位矿机文件、收端口或重启。 +5. 你点头后才停容器、占位矿机文件、收端口、重启或执行 [分阶段加固](references/hardening.md)。 + +加固会先验证非 root 密钥登录、SSH 配置生效值、实际防火墙后端、Fail2ban 日志后端和 Docker/k8s 暴露面;不会要求把密码发给 Agent,也不会默认删除 SSH drop-in 或把 Docker 的 `iptables` 改成 `false`。 + +已确认需要修复时,Agent 会先生成计划,再按批准的项目执行。当前自动修复仅覆盖 SSH 文件权限和 Fail2ban;每次会备份并支持回滚,防火墙、SSH 策略和恶意文件仍需人工确认。 ## 如何判断完成 diff --git a/skills/host-sec-check/SKILL.md b/skills/host-sec-check/SKILL.md index 220698b..3bfad97 100644 --- a/skills/host-sec-check/SKILL.md +++ b/skills/host-sec-check/SKILL.md @@ -1,13 +1,12 @@ --- name: host-sec-check description: >- - Linux/VPS 主机安全检查与入侵取证:先取证再处置。覆盖矿机、ld.so.preload 进程隐藏、带宽挂机容器、对外端口、SSH/Docker/k8s 入口、cron/systemd 持久化、hugepage/OOM。 - Use when: 安全检查, 被黑, 入侵, 矿机, 排查端口, 对公网放开, rpcbind, 挂机赚钱, Portainer, 安全审计, host security, compromise, cryptominer, exposure, /host-sec-check. + Linux/VPS 主机安全检查、入侵取证与用户确认后的分阶段加固:覆盖矿机、隐藏进程/ld.so.preload、对外端口、SSH/Docker/k8s、cron/systemd、hugepage/OOM、SSH/防火墙/Fail2ban。触发:安全检查、安全加固、新机初始化、被黑、入侵、矿机、排查端口、对公网放开、host security、hardening、compromise、/host-sec-check。 --- # host-sec-check -对一台 Linux 主机做安全检查。默认 **只读取证 + 出报告**。清理、关端口、重启、轮换密钥必须用户明确同意后再做。 +对一台 Linux 主机做安全检查。默认 **只读取证 + 出报告**。清理、关端口、重启、轮换密钥和安全加固必须用户明确同意后再做。 需要:SSH 目标(`user@host` 或 ssh config 别名)、哪张网卡/IP 算公网。没有公网 IP 就只报「监听面」,并标明未做外部探测。 @@ -24,6 +23,8 @@ description: >- 3. **sudo 失败不要停。** 用户在 `docker` 组时,用特权容器 `nsenter` 看宿主机(见 [references/access.md](references/access.md))。 4. **怀疑进程隐藏时,不要只信宿主机 `ps`。** 从**不加载宿主机 `ld.so.preload` 的容器**读 `/proc` 和文件。 5. 宿主机 `chattr` 可能被隐藏库挡住;改 immutable 文件用 **Ubuntu/Alpine 自带 chattr** 操作 bind-mount 的 `/host/...`。 +6. 不要把 root 密码、临时密码、私钥、webhook 或 token 作为命令行参数、环境变量或聊天内容传递;优先使用现有密钥、控制台或交互式 `passwd`。 +7. 加固不是“改 SSH 端口”竞赛:端口更改只减少噪声,必须先确认云安全组、主机防火墙和第二条登录路径。 收集脚本(可选,先跑再解读): @@ -33,9 +34,19 @@ bash /scripts/collect.sh USER@HOST `` 是本 skill 目录。脚本只采集,不修改系统。 +自动修复默认先生成计划: + +```bash +bash /scripts/remediate.sh USER@HOST --plan --fix ssh-perms --fix fail2ban --ignore-ip ADMIN_CIDR +``` + +只有用户明确批准具体修复项后,才可使用 `--apply` 和精确确认串。每次应用前必须备份,应用后必须验证;脚本输出的 `BACKUP_ID` 可用于精确回滚。防火墙、SSH 登录策略、Docker/k8s 绑定、恶意文件、重启和密钥/token 轮换只生成计划,不由此入口自动执行。详细边界见自动修复资料。 + ## 流程 -按顺序做。每步记下「正常 / 可疑 / 未查到」。完整项见 [references/checklist.md](references/checklist.md)。 +按顺序做。每步记下「正常 / 可疑 / 未查到」。需要逐项展开时再读检查清单。 + +如果用户明确要求「加固」「新机初始化」「配置 SSH/防火墙」,先完成只读快照,再阅读并执行加固资料。加固采用分阶段、可验证、可回滚流程;不能把远程一键脚本直接当作安全默认值。 ### 1. 快照 @@ -54,7 +65,7 @@ bash /scripts/collect.sh USER@HOST ### 3. 持久化 -读 [references/persistence.md](references/persistence.md)。至少查: +发现持久化信号或需要深查时,再读持久化资料。至少查: - crontab(root 与所有用户)、`/etc/cron.*` - `/etc/systemd/system/*.service`、timers @@ -90,10 +101,36 @@ bash /scripts/collect.sh USER@HOST - `journalctl` / `auth.log` 在处置时间点附近;日志空也要记 - 出站连接:`ss -tp` 看陌生 IP(不要把结果里的密钥打出来) -### 8. 补漏(本次 vvps2 没做或做得浅的) +### 8. 补漏(本次检查没做或做得浅的) 见 checklist 后半:补丁、SUID、世界可写、意外用户、IPv6、定时器、内核模块、出站 C2、云安全组、备份是否可用。 +加固模式还要额外记录: + +- SSH 的 `sshd -t` 与 `sshd -T` 生效结果,不只看配置文件中的 grep +- 防火墙实际后端(nftables/iptables/ufw)及 IPv4/IPv6 规则 +- Fail2ban jail、日志后端、白名单和当前封禁状态 +- 云安全组/平台防火墙是否已放行管理入口(查不到必须标未验证) +- 新管理员的密钥登录是否能在第二条会话中成功,再关闭 root/密码登录 +- Docker 的端口绑定、特权、socket/宿主机挂载;不要无条件改 `daemon.json` +- 变更前备份、变更后验证、失败时的控制台回滚路径 + +## 检查清单(按需) + +需要逐项展开时,读取 [references/checklist.md](references/checklist.md)。 + +## 持久化资料(按需) + +发现持久化信号或需要深查时,读取 [references/persistence.md](references/persistence.md)。 + +## 加固资料(按需) + +用户明确要求加固时,读取 [references/hardening.md](references/hardening.md)。 + +## 自动修复资料(按需) + +用户批准执行修复时,读取 [references/remediation.md](references/remediation.md)。 + ## 报告格式 先给结论(是否沦陷、最可能入口、对外还开着什么),再给证据表,最后给 **需用户点头的处置清单**。不要默默清理。 diff --git a/skills/host-sec-check/references/checklist.md b/skills/host-sec-check/references/checklist.md index 433cd75..8b0b3f0 100644 --- a/skills/host-sec-check/references/checklist.md +++ b/skills/host-sec-check/references/checklist.md @@ -14,6 +14,7 @@ ## B. 身份与入口 - [ ] sshd 配置:端口、root、密码、Pubkey +- [ ] `sshd -t` 语法与 `sshd -T` 生效值;确认 `ssh.service` / `sshd.service` / `ssh.socket` 实际监听关系 - [ ] 所有 `authorized_keys`(含 `/root`、`/home/*`、git 用户) - [ ] `last` / `lastb` / ssh journal - [ ] `/etc/passwd` 里 UID 0、系统外用户、登录 shell @@ -53,6 +54,7 @@ - [ ] rpcbind :111、DB、Redis、应用明文端口 - [ ] 管理 SSH 是否可收到 VPN - [ ] 证书/域名入口是否可被 IP:端口绕过 +- [ ] 实际防火墙后端与 IPv4/IPv6 规则:UFW、nftables、iptables、firewalld ## G. 补漏(容易漏) @@ -68,7 +70,16 @@ - [ ] `.git` 或备份文件可下载 - [ ] 云 metadata 是否对容器开放 -## H. 报告与处置确认 +## H. 加固专用 + +- [ ] 变更前有带外控制台、配置备份和第二条 SSH 会话 +- [ ] 非 root 管理账号的密钥登录已验证,再收紧 root/密码登录 +- [ ] Fail2ban 日志后端、`ignoreip`、jail 状态和误封回滚路径 +- [ ] 补丁状态与是否需要重启已记录 +- [ ] Docker 端口绑定、特权、`docker.sock`/宿主机挂载已审查;没有无条件改 `daemon.json` +- [ ] 变更后从外部验证 IPv4/IPv6 端口和业务域名;未验证项已列出 + +## I. 报告与处置确认 - [ ] 结论表 - [ ] 用户确认后再改 diff --git a/skills/host-sec-check/references/hardening.md b/skills/host-sec-check/references/hardening.md new file mode 100644 index 0000000..dfccdb5 --- /dev/null +++ b/skills/host-sec-check/references/hardening.md @@ -0,0 +1,73 @@ +# 分阶段加固 + +本参考只在用户明确同意「加固 / 配置 / 修复」后使用。默认先做检查,不改变远端主机。 + +## 0. 变更前闸门 + +1. 确认目标主机、系统、维护窗口、业务端口和允许的管理来源。 +2. 确认有云控制台/VNC/串口等带外回滚入口;没有就不要同时改 SSH 和防火墙。 +3. 保留当前 SSH 会话,再准备第二条测试会话。先验证新会话成功,再关闭旧入口。 +4. 记录 `sshd -T`、`ss -lntup`、`ufw status verbose` 或 nftables/iptables 状态、Docker/k8s 入口。 +5. 将待改配置复制到受限的备份目录,记录时间和校验值;备份中可能有敏感信息,不要贴进聊天。 + +不要要求用户把 root 密码发给 Agent,也不要使用 `sshpass`、命令行密码、`SSHPASS` 或关闭 host-key 校验。优先复用现有 SSH key;必须创建临时密码时,用控制台或交互式 `passwd` 设置,并在密钥验证后删除/锁定。 + +## 1. 账号和 SSH + +先创建并验证一个非 root 管理账号,再收紧 root 和密码登录: + +- 用户加入发行版正确的管理组(Debian/Ubuntu 通常是 `sudo`,RHEL 系通常是 `wheel`)。 +- 用户的 `~/.ssh` 为 `0700`,`authorized_keys` 为 `0600`,目录和文件归属正确;只记录 key 指纹,不复制私钥或完整 key 内容。 +- 配置应放在发行版支持的 drop-in 中,例如 `/etc/ssh/sshd_config.d/60-host-sec-check.conf`,不要删除或改名所有现有 drop-in。 +- 基线建议:`PermitRootLogin no`、`PasswordAuthentication no`、`KbdInteractiveAuthentication no`(启用 MFA/键盘交互时例外)、`PermitEmptyPasswords no`、`PubkeyAuthentication yes`、`MaxAuthTries 3`。 +- `X11Forwarding`、TCP 转发、Agent 转发和 `AllowGroups` 要按业务需要逐项确认,不能为了“看起来更安全”无条件关闭。 +- 改端口不是必需项;如果确实改,先放行云安全组和本机防火墙,再在第二条会话中用新端口测试。IPv4 和 IPv6 规则都要确认。 + +每次变更都按这个顺序: + +```bash +sshd -t +sshd -T | grep -iE '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitempty passwords|maxauthtries) ' +systemctl reload ssh 2>/dev/null || systemctl reload sshd +# 从另一条终端测试新用户/新端口,成功后才关闭旧登录路径 +``` + +不要按 Ubuntu 版本号猜 SSH socket 模式。先看 `systemctl is-enabled ssh.socket`、`systemctl status ssh.socket ssh.service` 和 `ss -lntp`,按实际监听单元配置;改动后再次用 `sshd -T` 和 `ss` 验证。 + +## 2. 防火墙和暴露面 + +- 先识别实际后端:UFW、nftables、firewalld 和 Docker 规则可能同时存在;不能在不了解现有规则时直接 `default deny incoming` 或 reset。 +- 保留已有业务白名单,最小化放行管理来源;明确处理 IPv6,避免只收紧 IPv4。 +- 防火墙只解决入站路径,不代表云安全组已收紧。云平台规则查不到就写「未验证」。 +- Docker 默认会安装自己的转发规则,不能仅凭 `daemon.json` 中没有 `iptables` 就认定需要写入 `"iptables": false`。先检查现有规则、发布端口和业务依赖;优先修正 compose 的绑定(内部服务不发布、回源绑 loopback/VPN),必要时在 `DOCKER-USER`/nftables 设计明确策略。 +- 关闭 `rpcbind`、数据库、Docker API、Portainer、Cockpit、k8s API 等入口前,先确认没有业务或编排依赖,并从外部验证端口确实消失。 + +## 3. Fail2ban、补丁和日志 + +- 安装/启用 Fail2ban 前确认日志后端(`systemd` journal 或 `/var/log/auth.log`/`secure`),配置 `ignoreip` 为可信管理网段/VPN,而不是只写 loopback。 +- 变更后检查 `fail2ban-client status`、对应 jail、过滤器命中和当前封禁;避免把维护网段封掉。 +- 按发行版包管理器修补安全更新,并记录是否需要重启;不要把“执行了 apt upgrade”当成补丁覆盖证明。 +- 检查时间同步、journal 持久化和日志轮转;日志缺失本身是审计结果,不要静默当作正常。 +- `auditd`、自动安全更新和登录通知属于按业务选择的增强项,不是所有机器都应无条件启用。通知脚本必须避免泄露 token、密码和完整环境变量。 + +## 4. 验证和回滚 + +加固完成后至少验证: + +1. 第二条会话可用:新用户、密钥、预期端口、预期来源均成功。 +2. `sshd -t`、`sshd -T`、服务状态、监听端口和防火墙规则与目标一致。 +3. 从外部探测 IPv4/IPv6:应关闭的端口不可达,业务域名和必要入口仍可用。 +4. Fail2ban jail 正常;Docker/k8s 业务仍能启动,关键容器没有意外变成公网监听。 +5. 记录未验证项、重启需求和回滚命令。SSH 或防火墙失败时,优先从带外控制台恢复备份,不要在失联状态下反复猜配置。 + +## 与“一键加固脚本”的边界 + +一键脚本可以提供检查清单,但以下做法不能作为本 skill 的默认行为: + +- 在命令行、环境变量或最终报告打印密码; +- `StrictHostKeyChecking=no` / `UserKnownHostsFile=/dev/null`; +- 先要求开启 root 密码登录,再自动收紧; +- 删除/改名整个 `sshd_config.d`; +- 无条件开启密码认证或把 root 保留为 `prohibit-password`; +- 用 `sed` 拼接 JSON,或无条件把 Docker `iptables` 设为 `false`; +- 只 `sleep` 几秒后重启 SSH/防火墙,未通过第二会话和外部探测验证。 diff --git a/skills/host-sec-check/references/remediation.md b/skills/host-sec-check/references/remediation.md new file mode 100644 index 0000000..6658053 --- /dev/null +++ b/skills/host-sec-check/references/remediation.md @@ -0,0 +1,53 @@ +# 受控自动修复 + +自动修复必须建立在已完成取证、用户明确批准具体修复项、且 SSH key 和带外控制台可用的前提上。默认只生成计划,不修改远端。 + +## 支持的入口 + +```bash +# 只读计划 +bash /scripts/remediate.sh user@host --plan --fix ssh-perms --fix fail2ban --ignore-ip 203.0.113.10/32 + +# 应用单项低风险修复 +bash /scripts/remediate.sh user@host \ + --apply --fix ssh-perms --confirm 'APPLY user@host' + +# 应用 Fail2ban;必须提供维护来源白名单 +bash /scripts/remediate.sh user@host \ + --apply --fix fail2ban --ignore-ip 203.0.113.10/32 \ + --confirm 'APPLY user@host' + +# 使用脚本输出的 BACKUP_ID 回滚 +bash /scripts/remediate.sh user@host \ + --rollback 20261010T120000Z --confirm 'ROLLBACK 20261010T120000Z' +``` + +脚本使用现有 SSH key、BatchMode 和 host-key 默认校验,不接受密码参数,不使用 `sshpass`,不自动安装系统包。 + +## 修复边界 + +### 可以自动执行(仍需 `--apply`) + +- `ssh-perms`:只修正当前 SSH 用户的 `~/.ssh` 为 `0700`、`authorized_keys` 为 `0600`,不改 key 内容和 owner。 +- `fail2ban`:在独立的 `jail.d/host-sec-check.local` 写入 sshd jail;先备份、测试配置,再对已运行的服务 reload 并验证 jail。不自动启动或启用服务。 + +### 只生成计划,不由脚本执行 + +- SSH 端口、root 登录、密码认证、MFA、转发策略。 +- UFW/nftables/firewalld 规则和云安全组。 +- Docker/k8s 端口绑定、特权、socket/宿主机挂载。 +- 删除或隔离恶意文件、容器、镜像、服务和 `/etc/ld.so.preload`。 +- 重启、密钥/token 轮换和系统包安装。 + +## 备份与回滚 + +应用前在远端创建 `/var/backups/host-sec-check//`,保存受影响文件和权限元数据。Fail2ban 文件恢复、SSH 权限恢复和本次新建文件删除都只针对该备份 manifest 中的精确路径。 + +如果应用中途失败,报告已完成项、备份目录和回滚命令;不能把“备份已创建”当成“回滚已成功”。回滚后仍需重新检查 SSH、Fail2ban 和业务状态。 + +## Agent 行为 + +1. 先跑 `--plan`,把修复项、影响、备份目录和验证方式列给用户。 +2. 用户只批准某些项时,只传对应的 `--fix`,不能顺手扩大范围。 +3. 应用后读取脚本输出,逐项确认 `[已修复]`、验证结果和未执行项。 +4. SSH/防火墙/业务中断风险出现时停在计划阶段,转为人工确认,不用脚本猜测或重试。 diff --git a/skills/host-sec-check/scripts/collect.sh b/skills/host-sec-check/scripts/collect.sh index 4a5aa96..f8b6838 100755 --- a/skills/host-sec-check/scripts/collect.sh +++ b/skills/host-sec-check/scripts/collect.sh @@ -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 } diff --git a/skills/host-sec-check/scripts/remediate.sh b/skills/host-sec-check/scripts/remediate.sh new file mode 100755 index 0000000..a347083 --- /dev/null +++ b/skills/host-sec-check/scripts/remediate.sh @@ -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 <&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