事情发生在今年 9 月底。公司运维扩招,我带一个新来的同事熟悉生产环境。走之前我给了他 sudo 权限,方式很常规:在 /etc/sudoers.d/ 下建了一个文件,写了三行ops ALL=(ALL) NOPASSWD: ALL。当时我心里想的很简单:这小子是运维,有些排查命令需要 root,免密省得每次输密码,而且权限只对 ops 这个用户生效,问题不大。
三天后他找我,说生产库的数据目录不见了,pg_ctl 报找不到数据文件。我第一反应是磁盘满了或者机器被重启了,结果 df -h 显示磁盘空了一半,ls /var/lib/postgresql/ 下面干干净净,一个目录都没有。
翻 bash_history 的时候,我看到了一行 sudo rm -rf /var/lib/postgresql/*。那天他执行的是 sudo systemctl restart postgresql,命令敲错了,多打了一个 rm -rf,而那串路径被 shell 的通配符展开成了全部数据目录。整条命令因为有 NOPASSWD 所以没有二次确认,直接以 root 身份执行完毕。
从备份恢复花了 6 个小时,数据是回来了,但那次事故让我把 sudo 这套东西从头到尾重新学了一遍。这一篇就把我当时写错的 4 行规则和后来重建的一套做法写清楚,给同样需要给运维开权限的人参考。
想在服务器上给权限加一层可视化管控的话,可以试试 服务器安全狗Linux版(64位):它能实时盯着服务器上的异常登录和高危操作,配合 sudo 日志一起看,谁在什么时候提权做了什么基本能对上账,比每次手动翻 /var/log/auth.log 省事。

ALL=(ALL) 把运行身份也放开了先解释一下语法。ops ALL=(ALL) NOPASSWD: ALL 这行里一共四段:
ops 规则适用的用户;ALL(第一个) 规则适用的主机;(ALL) 可以切换到的目标身份,也就是用这条规则时能变成谁;NOPASSWD: ALL 免密执行哪些命令。
很多人只关注第一个 ALL 和最后一个 ALL,中间那个 (ALL) 很少有人看。而它恰恰是最关键的一段。
(ALL) 意味着这条规则允许执行 sudo -u 任意用户 任意命令。也就是说,拿到这条权限的人可以直接 sudo -u root ...,也可以 sudo -u postgres psql 连上生产库去执行任意 SQL,甚至 sudo -u www-data 去读应用进程的配置文件、拿走数据库密码。对我们这种应用和数据库跑在同一台机器上的架构来说,(ALL) 等于把整个系统的门禁都打开了。
正确的做法是:目标身份只写真正需要的那个。比如只允许切到 postgres 用户跑 psql,就写 (postgres);只允许用 root 身份跑 systemctl,写 (root)。把「能变成谁」这个维度收窄,权限就从一个平面变成了几条互不相通的通道。
NOPASSWD: ALL 让每个高危命令都没有刹车NOPASSWD 本身不是原罪,它和 PAM 的密码策略是两回事。真正的问题在于我给的是 ALL所有命令都免密。
Linux 的 sudo 有个特性:如果一条命令的完整路径在列表里匹配成功,sudo 就不再检查它是否是「子命令」了。反过来,如果我用 sudo /bin/rm * 这种带参数的形式写规则,它只匹配字面上的 /bin/rm 加任意参数,看起来是安全的。但很多人图省事写成不带路径、不带参数的 rm,这样 rm 会被解释成一个 命令前缀,rm -rf / 照样匹配通过。
更要命的是通配符。在 sudoers 里写路径时,shell 通配符不是由 shell 展开的,而是 sudo 自己在匹配命令时做匹配,所以 rm -rf /var/lib/postgresql/* 这种命令,在规则里要怎么写才既安全又不被绕过,需要非常小心。这也是我那次事故的直接原因我根本没想到要限制路径粒度。

后来我重建规则的时候才明白为什么大家都推荐用 visudo 和 Defaults 别名。因为手写规则时,你很容易在几百行里漏掉一个符号。
先把别名定义出来:
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"Defaults log_outputDefaults !visiblepwDefaults timestamp_timeout=15
secure_path 这一条尤其重要。它决定了 sudo 执行命令时用的是哪个 PATH。如果不设,某些环境下会出现 sudo 走的 PATH 和普通用户不一样,导致同一个 rm 在两条路径下指向不同文件这是另一种隐蔽的提权路径。我那次没设,纯粹是运气好。
timestamp_timeout=15 表示输入密码后 15 分钟内不用重复输入。默认值是 5 分钟,对需要连续排查的人来说有点烦,但比 30 分钟那种长超时安全得多。时间窗口开太长是那种平时没感觉、出事就是事故的配置,一台被人拿到会话的机器,攻击者能利用这个窗口做很多事。
log_output 则把 sudo 的会话输出单独记到日志里,配合下面的 I/O log 能看到具体执行了哪条命令。
这是最容易漏的一环。sudoers 的匹配逻辑是「最后一条命中的规则生效」,也就是说如果 /etc/sudoers 里还有别的地方给了更宽的权限,我新写的严格规则可能被覆盖掉。
我的做法是给每个运维账号配一个带有效期的规则文件:
ops-team ALL=(root) NOPASSWD: /usr/bin/systemctl restart postgresql, /usr/bin/journalctl -u postgresql, /usr/bin/tail -f /var/log/postgresql/*.log
然后再单独一个只读审计账号:
ops-read ALL=(root) NOPASSWD: /usr/bin/journalctl *, /usr/bin/tail *, /usr/bin/less, /usr/bin/grep
关键在于不要给 * 结尾的「全量命令」权限,尤其是带 rm、mv、dd、chown、chmod 这些的。如果确实需要,可以用命令别名把参数锁死,比如:
User_Alias OPS = ops1,ops2,ops3Cmnd_Alias RESTART_PG = /usr/bin/systemctl restart postgresql
还有一个务实的动作:每季度过一遍 sudo -l 的输出,把离职和转岗的人的规则清掉。sudoers 里残留的幽灵账号比技术漏洞危险,因为它是你自己亲手开的门。
事故之后我把这套排查流程固化成了三步,排查越权问题时非常好用。
第一步,用 sudo -l -U 用户名 查这个账号实际能干什么。注意是 -U,可以查别人的权限,这个参数需要你本身是 root 或有足够权限。注意看输出里每一行的 (ALL : ALL) ALL,只要出现这个就是全权。
第二步,看 sudo 会话日志。在 /etc/sudoers 里加 Defaults log_output 后,日志会落在 /var/log/sudo/ 或者 syslog 的 sudo 标签下,用 journalctl _COMM=sudo 就能看到谁在什么时候提权、执行了什么命令、连 cwd(当前目录)都在记录里。
第三步,用 visudo -c 做语法体检。这个命令会检查整个 sudoers 的语法和有效性,包括发现 #includedir 目录下权限不对的文件。不要用普通的编辑器直接改 sudoers,因为一个分号写错,整台机器的 sudo 就全废了,而 visudo 会在保存前校验,保存失败自动还原。

说句可能会被人喷的话:很多生产服务器上根本不需要存在「能拿到 root shell」这种角色。我们后来把架构调了一下,改成普通运维账号通过跳板机登录,生产机上只保留受限的运维脚本入口,需要什么权限就调什么脚本,而不是给人一条万能的 sudo。
配套的还有两点:一是所有 sudo 走 SSH 密钥 + 二次验证,不共享密码,从源头掐掉「密码泄露 = 拿到 root」;二是敏感操作走审批流程,执行前有个确认,比如 rm、dd、chown -R 这些,可以在校验脚本里加一层路径检查,把通配符展开后的路径拦下来这一步其实就能直接避免我那次的事故。
另外,/var/log/auth.log 或者 journalctl 里的 sudo 记录建议接上告警,出现短时间内多次失败的 sudo、或者某个账号第一次执行高危命令就推一条通知。人是会被绕过的,日志不会。

复盘这次事故的时候,我最后悔的不是给了 NOPASSWD,也不是没写通配符限制,而是我给的是一个「方便」而不是「必要」。当时真正需要的权限其实只有三条命令:重启 postgresql、看日志、tail 日志文件。我却给了 ALL,等到出事才发现,那条多打了一次的 rm 之所以能删掉整个数据目录,是因为我从来没有把权限拆细过。
所以如果你也在给团队开 sudo,我建议从今天开始做三件事:把 (ALL) 换成具体的运行身份,把 NOPASSWD: ALL 换成具体的命令路径,然后加上有效期和季度复核。这三条不需要任何新工具,几分钟就能配完,但能挡掉绝大部分「手滑」型的事故。毕竟服务器上真正可怕的不是黑客,是你同事手滑的那一下。