收集 Linux 命令行执行的命令

本篇文章的目的为收集在命令行执行的全部命令,除了将全部的命令发送到 Elasticsearch 进行保存以外,还须要为敏感命令作告警。html

要作到这些的核心在于 PROMPT_COMMAND 这个环境变量,它的做用是,在出现 shell 命令输入提示符以前,做为命令来执行这个变量。shell

所以,咱们能够将这个变量定义为一个命令,而后看看它的效果:json

# 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。有了这个基础以后,咱们就可让其收集全部用户执行的命令。vim

read 命令

首先,因为 PROMPT_COMMAND 执行的时机是上个命令结束,命令行输出以前,所以咱们可使用 histroy 1 获取上一次执行的命令。可是因为该命令的输出结果前面会带上命令的序号,咱们须要去掉它。ruby

这样一来,第一个版的 PROMPT_COMMAND 的结果为:bash

# export PROMPT_COMMAND="history 1 | { read _ cmd; echo \$cmd; }"
复制代码

看起来挺复杂,其实很简单:markdown

  • histroy 1 的结果会传递到大括号,目的是去掉命令前面的序号;
  • 大括号至关于开启了一个匿名函数,将这两个命令做为一个总体,不过它不会开启一个子 shell;
  • read 是 shell 内部的子命令,它会将空格做为分隔符。

咱们通常使用 read 来读取键盘的输入,不过它还能够帮咱们去掉命令前的序号。因为咱们这里定义两个变量,所以 read 会对输入的结果使用空格分割 1 次,分割后的结果第一部分给变量 _,另外一部分给变量 cmd并发

很显然,序号给了 _,而后咱们 echo $cmd 就可以拿到上一次执行的命令了。只因此这里的 $ 前面加了转义符号 \,是由于这个命令是在 shell 环境下输入的,它会直接将 $cmd 做为变量解释了,使用转义是防止它直接解释。elasticsearch

其实你能够直接在命令行来测试 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
复制代码

还有最后一点是针对 HISTTIMEFORMATPROMPT_COMMAND 这两个环境变量的,为了不出现解析异常,最好将这两个变量都设为只读,这样任何用户都没法修改它的内容。若是不设置为只读,用户在本身家目录下的 .bashrc 或者直接在命令行就能够对其从新设置。

咱们能够将之都定义在 /etc/profile,若是想要在 /etc/profile.d 中单独用一个文件来保存,不要将 HISTTIMEFORMAT 的定义放在其中,否则其余用户登陆会报错,提示修改只读的变量。

unset HISTTIMEFORMAT
readonly HISTTIMEFORMAT
export PROMPT_COMMAND="/etc/collect_cmd.sh"
readonly PROMPT_COMMAND
复制代码