SSH 命令审计怎么做:终端能看到什么,哪些只能由服务端强制
「SSH 终端是怎样做成的」系列第 6 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),以及 Mist 服务端仓库里的 agent 安装脚本,只做删减。
要点
终端看到的是按键,不是 shell 最终执行的命令。行编辑、别名和变量展开、脚本以及其他 SSH 客户端都能绕过它,所以客户端的命令检查只能起提示作用。真正的强制必须放在启动进程的服务端,例如用 sshd 的 ForceCommand 包装命令。终端输出里的带内标记可以被伪造,只能用于显示。
共用生产服务器的团队常常需要一道护栏:执行 rm -rf / 之前提醒一下,执行 DROP DATABASE 之前要有人再看一眼,事后能查到谁跑了什么。看上去最自然的地方是终端,因为它能看到每一次按键。终端适合提示,不适合强制。MistTerm 把这两边拆开:客户端只提醒,真正扣住命令的是启动进程的服务端。
终端无法知道什么会被执行
回顾第 1、2 篇:终端发出字节,收到字节。它看不到远端 shell 的命令行,只看到发出去的按键和回来的回显。实际执行的命令和按键之间,有很多终端无法建模的差别:
- 行编辑。 光标移动、Tab 补全、历史回调(
↑、Ctrl-R、!!)、readline 和 zsh 的各种 widget,都会改变命令行,和按键之间没有一一对应关系。 - 展开。 别名、函数、变量、通配符和命令替换都在 shell 内部完成。
$(echo rm) -rf x里根本没有终端能匹配的rm -rf。 - 间接执行。 脚本、
cron、sudo -s、tmux、再跳一层ssh、从文件里读命令执行的程序,执行的命令都不经过终端的输入路径。 - 其他客户端。 任何人都可以用原生 OpenSSH 连上去,完全绕开终端一侧的规则。
所以客户端的检查只能是给键盘前的人的尽力提示。强制必须发生在命令执行的地方,也就是服务器上负责启动进程的那个组件。MistTerm 团队功能的设计文档也是这么写的:命令审计的判定权威在「服务端 + 服务器侧 agent」,客户端本地审计只是「本地快捷提示」,不作为安全承诺。
客户端:本地规则引擎
MistTerm 客户端有一个规则引擎 CmdAuditEngine,策略从团队服务同步(/v1/teams/{team_id}/command-audit/sync,每 300 秒刷新一次),只有团队策略启用时才生效。判定顺序是固定的,首条命中即生效:
// src/core/cmd_audit.rs —— CmdAuditEngine::check(结构示意)
pub fn check(&self, command: &str) -> CmdAuditResult {
let cmd = command.trim();
if cmd.is_empty() || !self.is_active() { return allow_result(); }
// 1. 团队自定义规则,按顺序(命中 Allow 规则直接放行)
// 2. 内置「读·危险」 → policy.dangerous_action
// 3. 内置「读·敏感」 → policy.sensitive_action
// 4. 内置危险规则 → policy.dangerous_action
// 5. 内置「读·安全」/ 安全 → 放行
// 6. 都没命中 → policy.unknown_action
}
默认策略是:命中危险规则 Block(拦截),命中敏感规则 Confirm(确认),未知命令 Allow(放行)。三者团队都可以修改。
凡是客户端自己发起的命令,都会经过这个引擎:发到窗格的命令片段和收藏命令、跨主机的批量执行、内置 AI 助手提议的命令。这些场景下客户端确切知道自己要发什么,检查是精确的。
手敲的命令:在回车处把关
手敲的命令才是难点。v1.2.26 在按下回车的那一刻检查它们。策略生效时,回车键不会立刻发出 \r,客户端先用上次回车以来发出的按键还原这一行,再做检查:
/// 键盘回车:策略生效且本行可信时先交宿主审计,否则照旧直接发 `\r`。
fn submit_enter_key(&mut self) {
if self.typed_enter_request.is_some() {
// 上一次回车仍在等审计,不重复提交。
return;
}
if let Some(line) = self.typed_enter_gate_line() {
self.typed_enter_request = Some(line);
return;
}
self.release_typed_enter(); // 发送 "\r"
}
这种还原是刻意做成近似的。本地这一行不可信时,代码选择不把关:
/// 需要审计的手敲行;本地行缓冲不可信或不是 shell 命令行时返回 `None`(保持原行为)。
fn typed_enter_gate_line(&self) -> Option<String> {
if !self.typed_enter_gate || !self.connected { return None; }
if self.terminal.is_alt_screen() || self.lrzsz.is_active() || self.transfer_progress.is_some() {
return None; // 全屏程序(vim、less……)与文件传输
}
let line = self.typed_line_buffer.trim();
if line.is_empty() { return None; }
if Self::looks_like_secret_prompt(&self.terminal.cursor_line_text()) {
return None; // 光标所在行像密码 / 口令提示时不审计,避免把密码当命令处理
}
Some(line.to_string())
}
本地缓冲跟踪这些输入:
- 可打印字符和退格。
- Ctrl-U、Ctrl-C(清空整行)。
- Ctrl-W(删除一个词)。
它不模拟光标移动、补全和历史。比如用 ↑ 调出的一行,本地缓冲是空的,回车会直接放行,而不是拦截。源码注释特意提醒过另一种做法的后果:只要缓冲看起来不对就拦截,用户就会遇到「能左右移却不能回车」。
如果检查结果是拦截,或者用户在确认时选择了取消,这些字符其实早已逐键发到远端命令行上了。客户端会发送 Ctrl-U(0x15)清掉远端这一行,并清空本地缓冲:
/// 被拦截/取消的手敲行:字符早已逐键发到远端,向该窗格发 Ctrl+U 清掉远端输入行,并清空本地缓冲,
/// 否则下一次回车会把旧内容和新输入拼在一起发出去。
pub fn discard_typed_line(&mut self) {
self.typed_enter_request = None;
self.typed_line_buffer.clear();
self.pty_outbound_line_buffer.clear();
let _ = self.send_pty_input(&[0x15]);
}
Ctrl-U 在内核行模式和 readline 里默认都是删除整行。但它终究只是惯例,按键绑定不同的远端程序会有不同表现。
命令审计应该在哪里强制:服务端
Mist 服务端仓库为团队服务器提供了一个 agent 安装脚本 deploy/agent/install.sh,用一行 curl … | sudo bash 安装,纯 bash 加 curl,没有常驻进程。它登记这台机器,并安装一个 sshd ForceCommand 包裹脚本。配置了 ForceCommand 之后,sshd 不再执行客户端请求的命令,而是运行这个包裹脚本,把原本请求的命令放在 SSH_ORIGINAL_COMMAND 里传给它。
直接读安装脚本,可以看到包裹脚本做了什么:
CMD="${SSH_ORIGINAL_COMMAND:-}"
LOGIN_SHELL="${SHELL:-/bin/sh}"
# 交互式登录(没有命令): 照常给一个登录 shell, 不能直接退出, 否则人就登不上了
if [ -z "$CMD" ]; then exec "$LOGIN_SHELL" -l; fi
# 传文件(sftp 子系统)不是 shell 命令, 直接交给 sftp-server
# …
# 读不到配置时不审计, 照常执行: 配置里有这台机器的密钥, 只有 root 能读,
# 所以目前只有 root 账号的命令会被审计; 也包括被卸载了一半的情况。
[ -r /etc/mist-agent/config ] && . /etc/mist-agent/config 2>/dev/null || exec "$LOGIN_SHELL" -c "$CMD"
RESP=$(curl -sS -m 5 -X POST "$MIST_API/server/command-audit/check" …)
接下来分三种情况:
- 连不上审计服务:命令照常执行,同时在 stderr 说明这条命令没有经过检查,也不会留下记录。也就是 fail-open。
- 判定为
block或confirm(或allowed: false):命令不执行,并上报一条拦截记录。对confirm,提示信息会说明:直接用ssh 主机 命令执行时,没有交互确认的办法。 - 其他情况:执行命令并上报结果。
综合起来,默认安装脚本强制的范围是:非交互命令(ssh host 'cmd'、自动化脚本、CI),并且只针对 root。交互式登录拿到的是普通登录 shell,之后在里面敲的任何命令,包裹脚本都看不到。这是 ForceCommand 机制本身的边界:它拦截的是会话的初始命令,而不是 shell 后来执行的命令。
检查接口(POST /v1/server/command-audit/check)返回 allowed、action、命中的规则和一个记录 ID,不会签发任何放行令牌。
交互式 shell:一个可选的钩子
要在交互式 bash 里检查命令,需要在 bash 内部挂钩子。MistTerm 仓库里有一个可选脚本 scripts/fix-mist-agent-interactive.py 负责安装,它不属于默认的 agent 安装流程。钩子用的是 bash 的 DEBUG trap 配合 extdebug:
shopt -s extdebug
trap mist_audit_preexec DEBUG
开启 extdebug 后,如果 DEBUG trap 返回非零,bash 会跳过即将执行的那条命令(见 bash 手册 shopt / extdebug)。钩子把命令发到同一个检查接口,超时 1.5 秒,同样 fail-open,并打印一行标记给客户端:
MIST_AUDIT {"v":1,"action":"block","message":"…","rule":"…","command":"…"}
遇到 block 时返回 1,bash 跳过这条命令。遇到 confirm 时打印标记并返回 0,也就是说命令照常执行,标记只是让客户端把规则展示给用户。DEBUG trap 是一层便利功能,不是安全边界:用户自己控制自己的 shell,随时可以去掉 trap,而 zsh、fish、sh 等其他 shell 根本不在覆盖范围内。
解析带内标记
在客户端,ServerAuditProbe 位于会话和终端模拟器之间。它暂扣可能是标记开头的不完整行,把完整的 MIST_AUDIT 行从显示中剥离,转换成对话框事件:
/// 喂入一块 PTY 数据,返回应显示的字节与解析出的事件。
pub fn feed(&mut self, chunk: &[u8]) -> (Vec<u8>, Vec<ServerAuditEvent>) {
// 每个完整行:parse_mist_audit_line → 事件,否则原样显示;
// 末尾的半行:只有它仍可能变成标记时才暂扣
}
只在半行仍可能变成标记时才暂扣,这一点对延迟很重要。探测器会跳过行首的空白和控制序列,剩下的部分是 MIST_AUDIT 的前缀,或者这块数据结束在一个没写完的转义序列里,才会暂扣,普通回显则直接上屏。
带内标记和其他所有内容共用一条字节流,任何输出都可能包含它。cat 一个含有这行文字的文件,客户端就会弹出对话框。这之所以可以接受,只是因为标记不带任何权力:决定早已在服务器上做出,对话框只是告知。
客户端里还有一处遗留逻辑值得说明。用户确认了服务端的 confirm 之后,如果标记里带有非空的 token 字段,客户端会先发送 MIST_AUDIT_APPROVE\t<token>(见 src/ui/app.rs)。钩子只在检查响应里有 token 时才转发它,而服务端的检查响应没有这个字段,所以在目前的部署里这个分支不会执行。特意说明一下,免得读源码的人把它当成一套能用的放行机制。
小结
- 终端看到的是按键,不是命令。行编辑、展开和间接执行,决定了终端一侧的检查本质上只能是提示。
- MistTerm 客户端对自己发起的命令做精确检查,对手敲的命令在回车时做近似检查。本地这一行不可信时(备用屏幕、传输中、密码提示),它选择让开,而不是瞎猜。
- 强制属于服务端。默认 agent 通过
ForceCommand包裹 root 的非交互命令,连不上服务时 fail-open。交互式 shell 需要可选的 bash 钩子,它能拦截,但不会扣住confirm。 - 带内标记可以被任何输出伪造,只能用来展示,不能用来决策。
常见问题
SSH 客户端能可靠地审计命令吗?
不能。它看到的是按键而不是最终命令,其他客户端也能完全绕过它。客户端检查只是尽力而为的提示。
SSH 命令审计应该在哪里强制?
在启动进程的服务端,例如用 sshd 的 ForceCommand 包装命令。
终端输出里的带内标记可信吗?
不可信。任何程序输出都能伪造它们,只能用于显示,不能用于决策。