深色模式
安全基线检查脚本:自动化巡检落地
摘要:基线检查靠人肉做一次容易,持续做才难。本文带你写一个 200 行以内的 Bash 基线脚本:输出机器可读、退出码可当发布门禁、结果可长期归档对比。
适用环境
bash
cat /etc/os-release
bash --version | head -1
command -v jq && jq --version
command -v ssh && ssh -V1
2
3
4
2
3
4
操作步骤
1. 先定检查项:宁少而准
建议首批 10 项:SSH 配置、UID 0 账号、空口令、sudo 通配、口令有效期、全局可写文件、危险 SUID、防火墙默认策略、时间同步、审计服务。
2. 写脚本骨架
bash
cat >/usr/local/bin/sec-baseline.sh <<'SCRIPT'
#!/bin/bash
# 安全基线检查脚本 v1.0
# 用法: sec-baseline.sh [--format text|json]
set -uo pipefail
FORMAT="${1:---format}"
FORMAT="${FORMAT#--format=}"
[ "$FORMAT" = "--format" ] && FORMAT=text
PASS=0; FAIL=0; WARN=0
RESULTS=()
# 统一的检查函数:check <名称> <状态> <详情>
check() {
local name="$1" status="$2" detail="$3"
RESULTS+=("${status}|${name}|${detail}")
case "$status" in
PASS) PASS=$((PASS+1));;
FAIL) FAIL=$((FAIL+1));;
WARN) WARN=$((WARN+1));;
esac
}
# --- 1. SSH 禁止 root 登录 ---
if sshd -T 2>/dev/null | grep -qi "^permitrootlogin no"; then
check "ssh_no_root_login" PASS "PermitRootLogin=no"
else
check "ssh_no_root_login" FAIL "root 可直登"
fi
# --- 2. SSH 禁用口令认证 ---
if sshd -T 2>/dev/null | grep -qi "^passwordauthentication no"; then
check "ssh_no_password_auth" PASS "PasswordAuthentication=no"
else
check "ssh_no_password_auth" WARN "仍允许口令登录"
fi
# --- 3. UID 0 账号只有 root ---
uid0=$(awk -F: '$3==0 {print $1}' /etc/passwd | tr '\n' ' ')
[ "$uid0" = "root " ] && check "only_root_uid0" PASS "$uid0" \
|| check "only_root_uid0" FAIL "额外 UID0: $uid0"
# --- 4. 无空口令账号 ---
empty=$(awk -F: '$2=="" {print $1}' /etc/shadow 2>/dev/null)
[ -z "$empty" ] && check "no_empty_password" PASS "无空口令" \
|| check "no_empty_password" FAIL "空口令: $empty"
# --- 5. sudo 无 NOPASSWD ALL ---
if grep -REn "NOPASSWD:[[:space:]]*ALL" /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -q .; then
check "sudo_no_all" FAIL "存在 NOPASSWD:ALL"
else
check "sudo_no_all" PASS "无通配授权"
fi
# --- 6. 口令最长有效期 ---
maxdays=$(grep -E '^PASS_MAX_DAYS' /etc/login.defs | awk '{print $2}')
[ "${maxdays:-99999}" -le 90 ] && check "password_max_days" PASS "PASS_MAX_DAYS=$maxdays" \
|| check "password_max_days" WARN "PASS_MAX_DAYS=$maxdays"
# --- 7. 全局可写文件 ---
wwcnt=$(find /etc /usr /opt -xdev -type f -perm -0002 2>/dev/null | wc -l)
[ "$wwcnt" -eq 0 ] && check "no_world_writable" PASS "0 个" \
|| check "no_world_writable" WARN "$wwcnt 个"
# --- 8. 防火墙默认策略 ---
if command -v nft >/dev/null && nft list ruleset 2>/dev/null | grep -q "policy drop"; then
check "firewall_default_drop" PASS "存在 drop 策略"
elif command -v iptables >/dev/null && iptables -S 2>/dev/null | grep -q "^-P INPUT DROP"; then
check "firewall_default_drop" PASS "INPUT DROP"
else
check "firewall_default_drop" WARN "未发现默认拒绝"
fi
# --- 9. 时间同步 ---
if systemctl is-active --quiet chronyd || systemctl is-active --quiet systemd-timesyncd; then
check "ntp_enabled" PASS "时间同步运行中"
else
check "ntp_enabled" WARN "时间同步未运行"
fi
# --- 10. 审计服务 ---
if systemctl is-active --quiet auditd && [ "$(auditctl -l 2>/dev/null | wc -l)" -gt 0 ]; then
check "auditd_rules" PASS "$(auditctl -l | wc -l) 条规则"
else
check "auditd_rules" FAIL "auditd 未运行或规则为空"
fi
# --- 输出 ---
case "$FORMAT" in
json)
printf '{"host":"%s","date":"%s","pass":%d,"fail":%d,"warn":%d,"items":[' \
"$(hostname)" "$(date -Iseconds)" "$PASS" "$FAIL" "$WARN"
first=1
for r in "${RESULTS[@]}"; do
IFS='|' read -r st nm dt <<<"$r"
[ $first -eq 0 ] && printf ','
printf '{"status":"%s","item":"%s","detail":"%s"}' "$st" "$nm" "$dt"
first=0
done
printf ']}\n'
;;
*)
echo "===== 安全基线报告 $(hostname) $(date +%F) ====="
for r in "${RESULTS[@]}"; do
IFS='|' read -r st nm dt <<<"$r"
printf '%-6s %-24s %s\n' "$st" "$nm" "$dt"
done
echo "----- 合计: PASS=$PASS FAIL=$FAIL WARN=$WARN -----"
;;
esac
# 退出码约定:0 全部通过或有 WARN;1 存在 FAIL
[ "$FAIL" -gt 0 ] && exit 1
exit 0
SCRIPT
chmod +x /usr/local/bin/sec-baseline.sh
/usr/local/bin/sec-baseline.sh1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
3. 机器可读输出:接进流水线
bash
/usr/local/bin/sec-baseline.sh --format=json | jq '.items[] | select(.status=="FAIL")'1
作为发布门禁:
bash
/usr/local/bin/sec-baseline.sh --format=json > /tmp/bs.json || {
echo "基线检查未通过,终止发布"; jq '.items[]|select(.status=="FAIL")' /tmp/bs.json; exit 1; }1
2
2
4. 批量执行:多主机巡检
bash
cat >/usr/local/bin/sec-baseline-all.sh <<'EOF'
#!/bin/bash
OUT=/var/log/baseline/$(date +%F)
mkdir -p "$OUT"
while read -r h; do
[ -z "$h" ] && continue
ssh -o StrictHostKeyChecking=no "$h" /usr/local/bin/sec-baseline.sh --format=json \
> "$OUT/$h.json" 2>/dev/null || echo "$h 执行失败" >> "$OUT/errors.txt"
done < /etc/baseline-hosts.txt
echo "报告目录: $OUT"
EOF
chmod +x /usr/local/bin/sec-baseline-all.sh1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
5. 定时执行与趋势对比
bash
cat >/etc/cron.d/sec-baseline <<'EOF'
0 3 * * 1 root /usr/local/bin/sec-baseline.sh --format=json >> /var/log/baseline/weekly.jsonl 2>&1
EOF1
2
3
2
3
对比两周差异:
bash
jq -c '{host,date,fail}' /var/log/baseline/weekly.jsonl | tail -4
jq -r 'select(.fail>0) | .items[] | select(.status=="FAIL") | .item' \
/var/log/baseline/weekly.jsonl | sort | uniq -c | sort -rn | head1
2
3
2
3
6. 脚本自身的可靠性
- 只读检查,绝不自动修改系统。
- 每条检查有明确的判定命令,避免正则误判。
set -uo pipefail但不加-e,防止单项失败中断整轮。- 输出必须有时间戳与主机名,便于归档对比。
验证
bash
/usr/local/bin/sec-baseline.sh; echo "exit=$?"
/usr/local/bin/sec-baseline.sh --format=json | jq -c '.pass,.fail,.warn'
# 故意制造一项失败
useradd -o -u 0 testuid0 2>/dev/null && /usr/local/bin/sec-baseline.sh | grep uid0
userdel testuid0 2>/dev/null
# 检查无语法错误
bash -n /usr/local/bin/sec-baseline.sh && echo "语法 OK"1
2
3
4
5
6
7
2
3
4
5
6
7
判定标准:脚本 3 秒内跑完;JSON 可被 jq 解析;FAIL 时退出码为 1;报告可归档对比。
常见坑
脚本里顺手做了"自动修复"
自动修改会引发不可预期中断。巡检脚本只读,修复由带审批的变更流程执行。
加 set -e 导致中途退出
某项检查命令返回非 0(如 grep 没匹配)会中断整轮。用 set -uo pipefail 并在每项内处理。
检查逻辑用脆弱的字符串匹配
grep "PermitRootLogin no" 可能命中注释行。应改用 sshd -T 这类"输出真实生效配置"的命令。
报告不留历史,无法看趋势
只保留最新一份等于没有基线。用 jsonl 追加或按日期目录归档至少 12 周。
脚本跑得慢被关掉
避免在根文件系统做无限制 find。限定 -xdev 与具体目录。