收集 Linux 命令列執行的命令

陳順吉發表於2019-05-01

本篇文章的目的為收集在命令列執行的所有命令,除了將所有的命令傳送到 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
複製程式碼

相關文章