简介:面向运维安全场景的Windows/Linux基线核查脚本包,聚焦系统合规自查与等保测评准备,解决主机安全基线核查效率低、标准不统一的问题。压缩包共8个文件、总大小182KB,类型覆盖PowerShell脚本、Shell脚本、config.cfg配置模板、CSV结果输出、Markdown说明及Word基线文档:ps1与sh脚本分别承担Windows、Linux侧自动核查,docx文档给出各版本基线配置项与整改建议,txt报错处理提供常见异常排除思路,便于开箱即用。目前已有1174人学习。通过该资料,读者可获得一整套可落地的基线核查工具链——运行脚本即能采集主机安全配置并输出CSV清单,对照Word基线文档快速定位不合规项,再依据报错处理指引解决运行中的依赖或权限问题,同时README与cfg方便自定义阈值与扩展检查项,适合需要在内网环境中快速建立合规自查机制或准备等保测评的工程师使用。
1. 基线核查脚本:一台机器上线之前,谁替你把安全策略过一遍?
基线核查脚本,说白了就是把安全团队写好的 Windows 和 Linux 加固要求,翻译成机器能执行的检查动作。很多系统上线时漏扫报告看着干干净净,但密码策略到底有没有生效、SSH 是不是还开着 root 登录、账户锁定阈值有没有配置,漏扫根本不会告诉你。这些恰恰是安全基线里最常被复查的部分,也是基线核查脚本能帮你解决的问题。它适合系统运维、安全工程师、交付实施这三类人:手里有多台机器要过合规,又不想靠眼睛一台台翻配置。这份资源等于把 Windows 和 Linux 两侧的检查逻辑都封装好了,你拿到手以后主要做两件事:改基线值、跑脚本收结果。
2. 基线核查思路与检查项设计:先拆粒度,再写规则
2.1 为什么不能直接靠漏洞扫描器,而要单独做基线核查
漏洞扫描器的长处是探测端口、版本漏洞和弱口令,但它对“该配而没配”的项目基本无能为力。举个例子,扫描器能发现系统某个服务版本老旧,但它不会告诉你/etc/login.defs里的PASS_MAX_DAYS已经被改成 99999,也不会关心 Windows 本地安全策略里“密码长度最小值”是否还停留在 0。这些项目没有 CVE 编号,但在合规审计里就是实打实的扣分项。
所以基线核查的逻辑和漏洞扫描完全是两条线。基线核查关注的是“配置是否符合既定策略”,出发点是安全基线的检查表,而不是漏洞库。这也是我推荐你把基线核查脚本单独维护、而不是硬塞进扫描流程里的原因:它的数据源、判断依据和报告格式都不一样,混在一起只会让两边都不好维护。
2.2 跨平台核查的差异:Windows 和 Linux 的配置存储方式完全不同
Linux 的配置大部分集中在/etc下的文本文件,比如/etc/ssh/sshd_config、/etc/login.defs、/etc/pam.d/system-auth,用 grep、awk、sed 就能直接采集。Windows 则不一样,本地安全策略分散在注册表、secedit导出文件和组策略对象里,比如密码策略要通过secedit /export来读,账户锁定阈值在注册表HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System下面。
这意味着你没法用同一套采集命令跑两个平台,必须做两套采集逻辑。但输出格式可以完全一致,这也是这份脚本的关键设计:Linux 用 Bash,Windows 用 PowerShell,两套脚本各自负责采集和判定,最后都输出统一字段的 CSV 行,方便合并和报告。跨平台核查的基本盘就是把“采集差异”隔离在脚本内部,不让它影响后端的判定和展示。
2.3 检查项拆分:采集、判定、输出三层设计
我在写这类基线脚本时习惯把每个检查项拆成三层,避免一股脑把逻辑写在一个大脚本里。
采集层解决“从哪读、怎么读”的问题。Linux 下读配置文本,Windows 下查注册表或者解析 secedit 导出文件。这一层只负责拿原始值,不做任何结论。
判定层解决“拿到的值怎么算合规”的问题。这里要区分字符串比较和数值比较。比如PermitRootLogin no是字符串比较,PASS_MAX_DAYS90 是数值比较,账户锁定阈值 5 是阈值比较。判定结果有三种状态:COMPLIANT合规、NON_COMPLIANT不合规、REVIEW需要人工确认。
输出层解决“结果怎么给别人看”的问题。统一输出成一行 CSV 字段,至少包含主机名、检查项 ID、实际值、期望值、状态这五个字段。层与层之间不互相依赖,后续新增检查项时,只需要在采集层加一个函数,判定层加一条规则,输出层基本不用动。
下面是一个常用检查项的映射设计,我把它作为脚本的功能清单,你拿到脚本后也可以按这个表来核对检查项是否齐全。
| 检查域 | 检查项 | Linux 采集点 | Windows 采集点 | 合规基线值 |
|---|---|---|---|---|
| 密码策略 | 密码最大使用天数 | /etc/login.defs的PASS_MAX_DAYS | secedit 导出文件MaximumPasswordAge | 不大于 90 |
| 密码策略 | 密码最小长度 | /etc/pam.d/system-auth的pam_pwquality | secedit 导出文件MinimumPasswordLength | 不小于 12 |
| 账户策略 | 账户锁定阈值 | /etc/pam.d/system-auth的pam_faillock | 注册表LockoutBadCount | 5 次 |
| 远程管理 | 禁用 SSH root 登录 | /etc/ssh/sshd_config的PermitRootLogin | 不适用 | no |
| 服务安全 | 危险服务状态 | systemctl is-active | Get-Service | 不运行 |
| 防火墙 | 防火墙是否启用 | systemctl is-active firewalld | Get-NetFirewallProfile | 启用 |
| 日志审计 | 审计功能开关 | /etc/audit/audit.rules | secedit 导出文件AuditPolicy子项 | 开启 |
3. 双引擎脚本骨架:Bash 与 PowerShell 如何共享一套检查标准
3.1 Linux 侧脚本:用 Bash 函数做采集,用变量做基线配置
Linux 侧我建议写成函数式脚本,而不是一大串从上到下执行的命令。函数式的好处是每个检查项独立成块,出问题的时候可以单独调试。以下是一个最小可用骨架,覆盖了 SSH root 登录、密码过期天数和账户锁定阈值三个典型检查项。
#!/bin/bash # 基线配置区:这里的值就是你安全团队的策略标准 SSH_ROOT_LOGIN_EXPECTED="no" PASS_MAX_DAYS_EXPECTED="90" LOCK_THRESHOLD_EXPECTED="5" # 通用函数:写 CSV 输出行 # 参数说明:$1=主机名 $2=检查项ID $3=实际值 $4=期望值 $5=状态 function output_row() { echo "$1,$2,$3,$4,$5" } # 检查项1:SSH 是否禁用 root 登录 # 关键:grep 必须匹配 ^ 开头的行,否则会把注释里的示例配置也算进去 ssh_permit_root=$(grep -E "^PermitRootLogin" /etc/ssh/sshd_config | awk '{print $2}') if [ "$ssh_permit_root" == "$SSH_ROOT_LOGIN_EXPECTED" ]; then output_row "$(hostname)" "SSH_RootLogin" "$ssh_permit_root" "$SSH_ROOT_LOGIN_EXPECTED" "COMPLIANT" else output_row "$(hostname)" "SSH_RootLogin" "$ssh_permit_root" "$SSH_ROOT_LOGIN_EXPECTED" "NON_COMPLIANT" fi # 检查项2:密码最大使用天数 # 注意:login.defs 里可能有多个同名校验值,需要取最后一次生效的配置 pass_max_days=$(grep -E "^PASS_MAX_DAYS" /etc/login.defs | awk '{print $2}' | tail -1) if [ -n "$pass_max_days" ] && [ "$pass_max_days" -le "$PASS_MAX_DAYS_EXPECTED" ]; then output_row "$(hostname)" "PassMaxDays" "$pass_max_days" "$PASS_MAX_DAYS_EXPECTED" "COMPLIANT" else output_row "$(hostname)" "PassMaxDays" "$pass_max_days" "$PASS_MAX_DAYS_EXPECTED" "NON_COMPLIANT" fi # 检查项3:账户锁定阈值 # faillock 配置可能写在 system-auth 里,也可能在单独的 password-auth 里,两个文件都要查 lock_threshold=$(grep -E "pam_faillock" /etc/pam.d/system-auth /etc/pam.d/password-auth 2>/dev/null | grep -E "deny=" | sed 's/.*deny=\([0-9]*\).*/\1/' | head -1) if [ -n "$lock_threshold" ] && [ "$lock_threshold" -le "$LOCK_THRESHOLD_EXPECTED" ]; then output_row "$(hostname)" "AccountLockThreshold" "$lock_threshold" "$LOCK_THRESHOLD_EXPECTED" "COMPLIANT" else output_row "$(hostname)" "AccountLockThreshold" "$lock_threshold" "$LOCK_THRESHOLD_EXPECTED" "NON_COMPLIANT" figrep -E "^PermitRootLogin"里的^是这行脚本最容易翻车的地方。很多 sshd_config 里都有被注释掉的示例配置,比如#PermitRootLogin yes,如果不加^锚定,会被当成实际配置采集到,导致 Linux 侧永远输出异常值。awk '{print $2}'表示取行内第二个字段,因为配置格式是PermitRootLogin no,键名在第一列,值在第二列。
密码过期天数用了tail -1,是因为/etc/login.defs里可能出现重复的PASS_MAX_DAYS定义,而 PAM 模块实际生效的往往是最后一个。账户锁定阈值这边同时查了system-auth和password-auth两个文件,因为不同发行版的 PAM 栈引用路径不一样,只查一个文件容易出现“漏采”。
3.2 Windows 侧脚本:PowerShell 解析 secedit 与注册表
Windows 侧的采集逻辑比 Linux 更绕,因为密码策略不直接暴露在注册表里,需要先导出再解析。我一般用 secedit 导出本地安全策略到临时文件,然后用Select-String提取关键行。以下是 Windows 侧的对应实现。
# 基线配置区 $MinPassLenExpected = 12 $MaxPassAgeExpected = 90 $LockThresholdExpected = 5 # secedit 导出本地安全策略到临时目录 # 注意:导出文件路径不要放在系统盘根目录,UAC 可能拦截写操作 $secDir = "$env:TEMP\baseline_secedit" if (!(Test-Path $secDir)) { New-Item -ItemType Directory -Path $secDir | Out-Null } secedit /export /cfg "$secDir\secpol.cfg" | Out-Null # 检查项1:密码最小长度 # 解析方式:按行匹配,然后按 = 分割取右侧值 $minPassLenLine = (Select-String -Path "$secDir\secpol.cfg" -Pattern "^MinimumPasswordLength").Line $minPassLen = $minPassLenLine.Split('=')[1].Trim() # 检查项2:密码最大使用天数 $maxPassAgeLine = (Select-String -Path "$secDir\secpol.cfg" -Pattern "^MaximumPasswordAge").Line $maxPassAge = $maxPassAgeLine.Split('=')[1].Trim() # 检查项3:账户锁定阈值 # 这个值在 secedit 文件里可能没有,必须兜底给出 REVIEW 状态 $lockout = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "LockoutBadCount" -ErrorAction SilentlyContinue if ($null -ne $lockout) { $lockThreshold = $lockout.LockoutBadCount } else { $lockThreshold = "N/A" } # 输出统一 CSV 格式 # 说明:PowerShell 字符串拼接中的逗号转义交给脚本外部处理,这里只保证字段顺序一致 "$env:COMPUTERNAME,MinPassLen,$minPassLen,$MinPassLenExpected,COMPLIANT"secedit /export这个命令的坑在于:导出的文件编码和语言版本有关系。中文系统导出的文件默认是 ANSI 编码,直接用Select-String按行匹配没问题,但如果脚本里混用了 UTF-8 字符串,可能出现匹配不到的情况。注册表读取LockoutBadCount时,很多主机在默认策略下根本没有这个值,所以这里加了SilentlyContinue和空值判断,读不到就给“N/A”,而不是直接抛异常。Windows 侧的输出我用的是字面量拼接,实际项目中你可以把它封装成函数,和 Linux 侧output_row保持同样的参数顺序,这样两个平台的输出可以直接合并。
3.3 统一判定逻辑:一份 CSV 串起两台主机
两个平台脚本各自跑完后,关键一步是把结果合并到同一份 CSV 里。判断逻辑上我建议统一用COMPLIANT / NON_COMPLIANT / REVIEW三态,而不是简单的 PASS/FAIL。原因很简单:采集层拿不到值时,可能是“真没配置”,也可能是“配置格式不标准”,这两种情况都不能直接判不合规。
在实际脚本包中,判定逻辑被抽成了一个小函数,两个平台都调用同一套规则。比如数值型检查项要区分字符串为空、数值越界、取值异常三种情况;字符串型检查项要处理大小写不一致、行首注释干扰、多值并存。这部分的细节处理,决定了脚本在真实机器上的误报率高低。你要是拿到脚本后先在一台机器上对比手工核查结果,就能立刻感受到这三态判定的价值。
4. 报告、合并与批量巡检:从单机可用到能交差
4.1 报告结构:合规率、风险项和证据值
基线核查脚本最终要产出报告,报告的结构决定了你能不能拿它去交差。我在实际项目中常用的报告字段是:主机名、检查项 ID、检查项名称、实际值、期望值、状态、核查时间。再往后就是聚合统计,比如按主机计算合规率、按检查项统计不合规数量、输出 TOP 风险检查项。
报告里必须带上实际值而不只是状态。因为安全审计时对方一定会问“为什么不合规”,你只有给出“当前值是多少、期望值是多少”,才能快速定位问题。比如某个检查项状态是NON_COMPLIANT,实际值显示 99999,期望值是 90,不用猜就知道是密码过期策略被人改成了永不过期。
4.2 从单机到批量:多主机巡检怎么跑
单机脚本跑通了,接下来就是批量跑一批机器。Linux 侧的常见做法是基于 SSH 批量分发执行,Windows 侧则用 WinRM 远程执行 PowerShell。批量执行脚本时我建议把“执行”和“收集”分成两步,避免一台机器超时卡住整个流程。
# 批量执行示例:inventory.txt 保存主机 IP,一行一台 # 注意:生产环境建议使用 SSH 密钥认证,不要用密码明文 while read host; do # 关键:加 timeout 30,防止某台机器网络异常导致整个巡检挂起 timeout 30 ssh "$host" "bash /opt/baseline/linux_check.sh" > "result_${host}.csv" if [ $? -eq 0 ]; then echo "[OK] $host done" else echo "[FAIL] $host timeout or unreachable" fi done < inventory.txttimeout 30是批量巡检里性价比最高的防御措施。没有这行命令时,一台 SSH 连不上的机器会卡住进程好几分钟,几十台机器跑下来时间翻倍。Windows 侧用Invoke-Command批量执行时同理,建议加上-SessionOption (New-PSSessionOption -OperationTimeoutSec 30)来控制超时。
4.3 合并两平台结果:后端脚本统一汇总
Linux 和 Windows 各自生成 CSV 后,合并工作交给后端脚本处理。我用 Python 写了一个简单的合并脚本,它会自动匹配同主机名下的所有检查项,生成一份 HTML 报告。
import csv import glob from collections import Counter # 读取所有 CSV 文件,兼容 Linux 和 Windows 两个前缀 all_rows = [] for path in glob.glob("result_*.csv"): with open(path, encoding="utf-8") as f: reader = csv.DictReader(f, fieldnames=["hostname", "item", "actual", "expected", "status"]) all_rows.extend(reader) # 关键:同一台主机的同一检查项可能被不同脚本重复写入,需要按 hostname+item 去重 unique = {} for row in all_rows: key = (row["hostname"], row["item"]) unique[key] = row # 按主机统计合规率 stats = Counter((row["hostname"], row["status"]) for row in unique.values()) print("主机合规情况:") for host, status in sorted(stats): if status != "COMPLIANT": print(f" {host}: {status}")glob.glob("result_*.csv")这段逻辑依赖前面脚本的输出文件名格式,如果你在批量执行时改了文件名前缀,这里要同步改。csv.DictReader需要保证每行字段数一致,两套脚本的输出字段顺序必须严格按照前面的output_row来,否则合并时字段会错位。去重逻辑是关键,因为 Windows 和 Linux 混合环境下,可能同一个检查项在多个批次里被重复采集。
5. 避坑指南:基线核查脚本最容易翻车的四个现场
5.1 现象:用 root 跑完脚本,合规项反而变成了不合规
原因:某些配置在普通用户视角和 root 视角下完全不同。典型例子是 SSH 配置里的PermitRootLogin值,root 的 sshd_config 可能被/etc/ssh/sshd_config.d/目录下的片段覆盖,主配置文件里的值已经失效。脚本只读了主配置,自然拿到的是旧值。
解决:采集/etc/ssh/sshd_config之前,先用sshd -T获取实际生效配置。比如sshd -T | grep -i permitrootlogin,拿到的才是 sshd 真正加载的值。凡是遇到“配置被拆分成多个文件”的检查项,一律优先用服务自带的配置展示命令,而不是直接解析文件。
5.2 现象:Linux 脚本解析出的值总是带#或yes
原因:grep 时没有锚定行首,把注释示例也匹配进来了。#PermitRootLogin yes这种行在 sshd_config 里非常常见,直接grep PermitRootLogin会把注释行也捞出来,导致实际值误判为yes。
解决:所有配置解析必须用^锚定非注释行,先过滤空行再取字段。我一般会先grep -E "^PermitRootLogin",必要时再用awk '!/^#/ && NF'做二次过滤。这个坑在/etc/login.defs里同样存在,建议统一处理。
5.3 现象:Windows 中文系统下,PowerShell 解析 secedit 输出为空
原因:中文 Windows 的 secedit 导出文件里,“密码长度最小值”等策略名是中文列名,而不是英文列名MinimumPasswordLength。如果你的脚本是按英文关键字匹配的,拿到手就是空值。
解决:先导出一份 secedit 文件人工确认列名格式。稳妥的做法是用secedit /export /areas SECURITYPOLICY只导出安全策略区域,然后按“策略名=值”的通用结构解析,不要假设列名语言。再不行就用注册表HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System里可读的项来替代部分检查项。
5.4 现象:把 REVIEW 状态当成 NON_COMPLIANT,误报率飙升
原因:采集层拿不到值,可能是真的没配置,也可能是值格式不对,或者权限不够。如果脚本直接给NON_COMPLIANT,报告里的不合规数量会虚高,安全团队就得逐条人工复核,反而浪费大量时间。
解决:所有“读不到值”的情况一律给REVIEW,单独走人工确认流程。在报告聚合时,把 REVIEW 和 NON_COMPLIANT 分开统计,不要让 REVIEW 项影响合规率计算。这是基线脚本设计上最容易忽略、但实际使用中价值最大的一个细节。
6. 进阶:从只读核查到自动修复,以及我最后踩的一次坑
脚本能核查了,自然会想让它顺手把问题修掉。我在实际项目中把这个能力做成了“修复模式”:脚本默认只读,只有显式传--apply参数时才执行修复动作。修复前强制备份配置,所有写操作保持幂等。
# 修复模式骨架:先备份,再改配置,最后重载服务 DRY_RUN=${DRY_RUN:-1} # 默认只读,DRY_RUN=0 时执行修复 if [ "$DRY_RUN" -eq 0 ]; then cp /etc/ssh/sshd_config "${BACKUP_DIR}/sshd_config.$(date +%Y%m%d%H%M%S)" # 关键:用 sed 加 ^ 锚定,避免修改注释示例 sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config systemctl reload sshd fi这里有个重要细节:sed的正则里我加上了^#\?,意思是“行首的 # 可有可无”,这样既能修改已生效的配置,也能把注释掉的旧配置一并替换,避免下次重载服务时注释行重新生效。
定时巡检方面,Linux 用 cron 每周一凌晨跑一次,Windows 用任务计划程序触发 PowerShell 脚本,报告按日期归档,保留最近 30 天。脚本包里的成品就是这样设计的,你只需要按自己的主机列表调整基线值。
最后一次踩坑是自动修复上线时发生的。某台机器 sshd_config 主文件被我改对了,但该机器在/etc/ssh/sshd_config.d/下还有一个片段文件,里面写着PermitRootLogin yes,结果 sshd 实际生效的还是允许 root 登录。服务重载后我才发现,那台机器等于白改了一场。从那以后,我每次写自动修复逻辑,都强制要求采集逻辑先确认“改的文件就是实际生效的文件”,再决定要不要动--apply。这个习惯沿用到现在,希望帮到你。
本文还有配套的精品资源,点击获取