本篇文章的目的为收集在命令行执行的所有命令,除了将所有的命令发送到 Elasticsearch 进行保存之外,还需要为敏感命令做告警。
要做到这些的核心在于 PROMPT_COMMAND 这个环境变量,它的作用是,在出现 shell 命令输入提示符之前,作为命令来执行这个变量。
因此,我们可以将这个变量定义为一个命令,然后看看它的效果:
# export PROMPT_COMMAND="date '+%F %T'"
2019-04-23 11:25:53 # 在出现下面的提示符之前执行了 date 命令
# a
-bash: a: command not found
2019-04-23 11:25:56 # 再次出现
# v
-bash: v: command not found
2019-04-23 11:25:58 # 每次命令行提示符出现之前它都会出现
这就相当于每执行一次命令就会执行一次 PROMPT_COMMAND。有了这个基础之后,我们就可以让其收集所有用户执行的命令。
read 命令
首先,由于 PROMPT_COMMAND 执行的时机是上个命令结束,命令行输出之前,因此我们可以使用 histroy 1 获取上一次执行的命令。但是由于该命令的输出结果前面会带上命令的序号,我们需要去掉它。
这样一来,第一个版的 PROMPT_COMMAND 的结果为:
# export PROMPT_COMMAND="history 1 | { read _ cmd; echo \$cmd; }"
看起来挺复杂,其实很简单:
histroy 1的结果会传递到大括号,目的是去掉命令前面的序号;- 大括号相当于开启了一个匿名函数,将这两个命令作为一个整体,不过它不会开启一个子 shell;
- read 是 shell 内部的子命令,它会将空格作为分隔符。
我们一般使用 read 来读取键盘的输入,不过它还可以帮我们去掉命令前的序号。由于我们这里定义两个变量,因此 read 会对输入的结果使用空格分割 1 次,分割后的结果第一部分给变量 _,另一部分给变量 cmd。
很显然,序号给了 _,然后我们 echo $cmd 就能够拿到上一次执行的命令了。只所以这里的 $ 前面加了转义符号 \,是因为这个命令是在 shell 环境下输入的,它会直接将 $cmd 作为变量解释了,使用转义是防止它直接解释。
其实你可以直接在命令行来测试 read 的效果:
# read x y z
23232 xxxxx ewewewe ssssss zzzzz
# echo "$x | $y | $z"
23232 | xxxxx | ewewewe ssssss zzzzz
收集相关信息
我们光收集历史命令是没什么用的,还应该收集如下信息:
- 命令执行的时间
- 执行命令时所在的目录
- 当前执行命令的用户
- 登录的用户(登录后可能会 su 切换到其他用户)
- 用户所在的 tty(可能会同时开多个 shell)
- 登录的 ip
- 执行的命令
以上这些信息都需要执行命令来获取,组合起来就是这样的:
date "+%F %T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; history 1 | { read _ cmd; echo $cmd; }
别急着使用,你需要看看有哪些命令,以及这些命令是干啥的就行,因为这毕竟不是最终版本。
如果定义了 HISTTIMEFORMAT 环境变量,history 的输出结果可能就不是我们想要的了。因此我们应该在用户登录之后将 HISTTIMEFORMAT 设置为空,为了防止用户修改,你可以将它设置为只读。但是一旦将其设置为只读,那么在系统重启之前,这个值无法修改。
vim /etc/profile
export HISTTIMEFORMAT=""
readonly HISTTIMEFORMAT
logger 命令
我们拿到这些信息之后肯定不能只是将其输出,而是将其存放在一个文件中。如果直接将其追加到一个文件中,是会有问题的。操作系统上肯定不止一个用户,不同的用户都会执行命令,那么这个日志文件的属主属组应该改成啥?日志文件要不要切割?要不要删除?这些都是要考虑的问题。
虽然我们最终都是要输出到文件中,直接追加的方式虽然简单,但是不好控制。最好的方式是使用 logger 命令将其输出 rsyslog,让 rsyslog 帮我们写到文件中,依托 rsyslog 强大的功能,我们可以对日志文件做更多的事情。
logger 命令我们只会用到两个选项:
-p:指定输出的基础设施和日志等级;-t:指定 tag
我们需要修改 rsyslog 的配置文件,让其接收我们发送给它的日志并输出到文件中。这里将日志输出到 /var/log/bashlog。
# vim /etc/rsyslog.d/bashlog.conf
local6.debug /var/log/bashlog
检查 rsyslog 配置,然后重启:
# rsyslogd -N1
# /etc/init.d/rsyslog/restart
然后测试一把,看看 /var/log/bashlog 是否存在你想要的内容。
echo "hehe" | logger -t bashlog -p local6.debug
如果将这些都赋值给 PROMPT_COMMAND 变量,会显得很复杂,我们可以将这些命令定义到一个文件中,然后将这个文件赋值给 PROMPT_COMMAND。
# vim /etc/collect_cmd.sh
echo `date "+%F_%T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; history 1 | { read _ cmd; echo $cmd; }` | logger -t bashlog -p local6.debug
# chmod +x /etc/collect_cmd.sh
# export PROMPT_COMMAND="/etc/collect_cmd.sh"
需要注意的是,脚本就写这一行,不要加上 #!/bin/bash,否则 history 命令执行不会有任何结果,原因不明。
最终用户每在命令行执行一次命令,就会在日志文件中增加一条类似这样的行:
Apr 25 14:23:03 localhost bashlog: 2019-04-25_14:23:03 root root pts/0 10.201.2.170 cat /etc/collect_cmd.sh
前面的日期日志、主机名、程序名都是 rsyslog 自动添加的,后面才是我们发送过去的内容。
升级 rsyslog
我们之所以能够收集用户执行的命令,核心就是 PROMPT_COMMAND。虽然我们现在定义好了它,但是难免它被人修改(普通用户也行),用户只要登录后执行 unset PROMPT_COMMAND,那么你的一切设置都将付诸东流。所以最好的方式就是将这个变量设置为只读。
前面我们已经将所收集到的日志存放到了文件中了,其实到这一步日志收集已经完成,但是为了便于之后发送到 Elasticsearch,我准备将这些日志以 json 格式写入到文件中。
怎么做呢?还是通过 rsyslog。只不过 CentOS6 默认的 rsyslog 版本太低,功能有限,需要将其升级到最新版才行。
升级 rsyslog 没有什么风险,我司生产环境升级到 rsyslog8 跑了两年多,没有任何问题。
在官网可以直接下载对应的 yum repo 文件,然后 yum update rsyslog 就升级到最新版了。
我这里将所有 rsyslog 相关的包都下载下来,然后创建了一个本地的 yum 仓库,便于内网机器下载升级。
升级后需要修改一行配置,有些配置不兼容:
# 修改前
*.emerg *
# 修改后
*.emerg :omusrmsg:*
修改完成后直接重启:
service rsyslog restart
解析日志
rsyslog 通过 mmnormalize 模块进行日志解析,解析后的内容为 json 格式,而这个模块使用的解析功能来自于 Liblognorm。关于 Liblognorm 的解析语法直接看官方文档即可。
为什么要解析成 json 格式?主要的原因是 Elasticsearch 存储的就是 json 格式的数据,我们可以直接将解析好的数据直接发送到 Elasticsearch,而无需使用 Logstash 解析。
先下载模块:
yum install rsyslog-mmnormalize liblognorm5-utils
liblognorm5-utils 用来检测解析规则是否正确,下面会用到。
当我们将执行的命令发送给 rsyslog 后,我们需要解析的是下面的内容,不包括我们上面看到的 rsyslog 自动添加的时间日期等信息。
2019-04-30_14:01:45 /root root root pts/0 10.201.2.170 vim hehe
它会在开头添加一个空格,这个空格是怎么来的我也不清楚,因此我们要预留一个空格。
mmnormalize 使用时,需要指定一个解析库。这个解析库遵循 liblognorm5 的语法:
# vim /etc/bashlog.rb
version=2
# 冒号后面的空格就是上面提到的空格
rule=: %
time:word
# 两个百分号之间的空格是 date 和 pwd 命令之间的空格
% %
directory:word
% %
exec_user:word
% %
login_user:word
% %
tty:word
% %
src_ip:ipv4
% %
command:rest
%
这个文件就是一个解析库,用于解析上面的内容。虽然以 rb 结尾,但是和 ruby 没有关系。
简单的解释下它的作用:
- version=2 必须处于第一行,并且这一行只能是这几个字符,不能加任意字符进去。它表示使用的是 v2 引擎,官方推荐使用 v2,但是 v2 不一定比 v1 功能更丰富,但是我们用够了。如果没写,或者写错了,将使用 v1 引擎;
- 规则的写法就是
rule=,它后面的冒号:用来分割 tag 的,也就是说等号和冒号之间可以加上 tag。我们不需要 tag,但是得把冒号写上; - 冒号
:就是字段解析了,要解析的字段使用百分号%包起来。百分号中通过冒号:进行分割,冒号前是字段的名称(json 中的对象名),冒号后是 Liblognorm 内置的字段类型,字段类型后面可以加参数,使用中括号{}引用,只是上面没有使用,每个字段的参数都不一样,有的有,有的没有; - 百分号中允许存在空格和换行符,这样就可以写成多行,而不用都写在一行,看起来更美观;
- 使用 Liblognorm 进行解析时,空格是一对一的。假如两个字段间有三个空格,那么写解析规则时,两个百分号之间必须要有三个空格。有时你无法确定空格数量怎么办?使用 whitespace 这种类型;
- 字段类型(这里只列出常用的,更多的看官方文档即可):
- word:空格外的任意字符,也就是看到空格后就终止匹配;
- whitespace:匹配所有空格,直到碰到第一个非空格字符。也就是在有不止一个空格的情况下使用它非常合适;
- date-rfc3164:rsyslog 的时间字段;
- ipv4:ipv4 地址;
- rest:直接匹配到行尾;
- -:匹配但不显示,它一般用于丢弃字段,比如它和 whitespace 类型配在一起就非常合适。
测试一把解析库:
# echo " 2019-04-30_14:01:45 /root root root pts/0 10.201.2.170 vim hehe" | lognormalizer -r /etc/bashlog.rb -e json
{ "command": "vim hehe", "src_ip": "10.201.2.170", "tty": "pts\/0", "login_user": "root", "exec_user": "root", "directory": "\/root", "time": "2019-04-30_14:01:45" }
这就是解析后的结果,唯一的缺点就是会在 / 前面加上转译符 \。
现在只需要简单的配置下 rsyslog 就能够将解析后的 json 数据保存到文件中。
# vim /etc/rsyslog.d/bashlog.conf
# 加载模块
module(load="mmnormalize")
template(name="all-json" type="list"){
property(name="$!all-json")
constant(value="\n") # 如果没有这行,解析后的信息不会换行
}
if $syslogfacility-text == 'local6' and $syslogseverity-text == 'debug' then {
action(type="mmnormalize" rulebase="/etc/bashlog.rb")
action(type="omfile" File="/var/log/bashlog" template="all-json")
}
该文件之前的内容可以删掉了。
我们首先定义了一个模板,这个模板是配合解析用的,解析一条消息,就将解析后的 json 格式的信息保存在 $!all-json 这个变量中,然后就可以定义 action 将其保存在文件中,或者 NoSQL 中。
本来是打算将其直接发送到 kafka/elasticsearch,但是考虑到 rsyslog 只会当时将消息发送出去,如果发送不成功它不会重发,因此还是将其保存到文件中,然后通过 filebeat 对文件进行读取并发送。
通过 omfile 还能定义文件的属主属组,文件权限等,默认属主属组为 root,权限 600。
重启 rsyslog 之后,我们就可以在 /var/log/bashlog 中看到我们执行的命令了。
为了让 PROMPT_COMMAND 用户登录就生效,我们可以将之定义在 /etc/profile 中,且将其定义成只读。
vim /etc/profile
export PROMPT_COMMAND="/etc/collect_cmd.sh"
readonly PROMPT_COMMAND
改进
通过上面的方式我们可以收集命令行日志并将其解析,但是还是会存在问题。当你在命令行空回车而不输入任何东西时,执行 history 1 会获得上一次执行的命令,没有什么问题。但是如果你上一次都为空,那么你这次的收集的命令就是空,解析会失败。
你会从解析的日志文件中看到这样的内容:
{ "originalmsg": " 2019-05-01_14:19:19 \/home\/user1 user1 root pts\/0 10.201.2.170", "unparsed-data": "" }
我们解析规则是默认登录后面的 ip 后面还会有内容,当其没有内容时就会解析失败。这种情况会出现在你使用 su - 命令切换到其他用户,且切换后直接空回车。
针对这样的情况,我们应该判断用户输入的命令是否为空,如果为空就直接退出脚本。因此我们的 /etc/collect_cmd.sh 可以做如下修改:
cmd=`history 1 | { read _ cmd; echo $cmd; }`
[ -z "$cmd" ] && exit # 当其为空时就直接退出
echo `date "+%F_%T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; echo $cmd` | logger -t bashlog -p local6.debug
还有最后一点是针对 HISTTIMEFORMAT 和 PROMPT_COMMAND 这两个环境变量的,为了避免出现解析异常,最好将这两个变量都设为只读,这样任何用户都无法修改它的内容。如果不设置为只读,用户在自己家目录下的 .bashrc 或者直接在命令行就可以对其重新设置。
我们可以将之都定义在 /etc/profile,如果想要在 /etc/profile.d 中单独用一个文件来保存,不要将 HISTTIMEFORMAT 的定义放在其中,不然其他用户登录会报错,提示修改只读的变量。
unset HISTTIMEFORMAT
readonly HISTTIMEFORMAT
export PROMPT_COMMAND="/etc/collect_cmd.sh"
readonly PROMPT_COMMAND