[譯] 在 Apache 和 Nginx 日誌裡檢測爬蟲機器人

呵呵哈哈嘻嘻呼呼呵呵發表於2019-03-04

在 Apache 和 Nginx 日誌裡檢測爬蟲機器人

現在阻止基於 JavaScript 追蹤的瀏覽器外掛享有九位數的使用者量,從這一事實可以看出,web 流量日誌可以成為一個很好的、能夠感知有多少人在訪問你的網站的地方。但是任何監測過 web 流量日誌一段時間的人都知道,有成群結隊的爬蟲機器人在爬網站。然而,在 web 伺服器日誌裡分辨出機器人和人為產生的流量是一個難題。

在這篇博文中,我將帶你們重現那些我在建立一個基於 IPv4 所屬和瀏覽器字串(browser string)的機器人檢測指令碼時用過的步驟。

本文中用到的程式碼在這個 程式碼片段 裡。

IP 地址所屬資料庫

首先,我會安裝 Python 和一些依賴包。接下來的指令會在一個新的 Ubuntu 14.04.3 LTS 安裝過程中執行。

$ sudo apt-get update
$ sudo apt-get install 
    python-dev 
    python-pip 
    python-virtualenv複製程式碼

接下來我要建立一個 Python 虛擬環境,並且啟用它。通過 pip 安裝庫時,容易遇到許可權問題,這樣可以緩解這種問題。

$ virtualenv findbots
$ source findbots/bin/activate複製程式碼

MaxMind 提供了一個免費的資料庫,資料庫裡有 IPv4 地址對應的國家和城市註冊資訊。和這些資料集一起,他們還發布了一個基於 Python 的庫,叫 “geoip2”,這個庫可以將他們的資料集對映到記憶體對映的檔案裡,並且用基於 C 的 Python 擴充套件來執行非常快的查詢。

下面的命令會安裝它們的包,下載、解壓它們在城市那一層的資料集。

$ pip install geoip2
$ curl -O http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz
$ gunzip GeoLite2-City.mmdb.gz複製程式碼

我看過一些 web 流量日誌,並且抓取出來一些恰好請求了「robots.txt」的流量。從那個列表裡,我重點檢查了經常出現的 IP 地址中的一些,發現不少 IP 其實是屬於主機和雲服務提供商的。我想知道是不是有可能攢出來一個列表,無論完不完整,包括了這些提供商所有的 IPv4 地址。

Google 有一個基於 DNS 的機制,用於收集它們用於提供雲的 IP 地址列表。這個最初的呼叫將給你一系列可以查詢的主機。

$ dig -t txt _cloud-netblocks.googleusercontent.com | grep spf複製程式碼
 _cloud-netblocks.googleusercontent.com. 5 IN TXT "v=spf1 include:_cloud-netblocks1.googleusercontent.com include:_cloud-netblocks2.googleusercontent.com include:_cloud-netblocks3.googleusercontent.com include:_cloud-netblocks4.googleusercontent.com include:_cloud-netblocks5.googleusercontent.com ?all"複製程式碼

以上闡明瞭 _cloud-netblocks[1-5].googleusercontent.com 將包含 SPF 記錄,這些記錄裡包括他們實用的 IPv4 和 IPv6 CIDR 地址。像如下這樣查詢所有的五個地址,應當會給你一個最新的列表。

$ dig -t txt _cloud-netblocks1.googleusercontent.com | grep spf複製程式碼
_cloud-netblocks1.googleusercontent.com. 5 IN TXT "v=spf1 ip4:8.34.208.0/20 ip4:8.35.192.0/21 ip4:8.35.200.0/23 ip4:108.59.80.0/20 ip4:108.170.192.0/20 ip4:108.170.208.0/21 ip4:108.170.216.0/22 ip4:108.170.220.0/23 ip4:108.170.222.0/24 ?all"複製程式碼

去年三月,基於 Hadoop 的 MapReduce 任務,我嘗試著抓取了整個 IPv4 地址空間的 WHOIS 細節,並且釋出了一篇 部落格文章。這個任務在過早結束之前,跑了接近兩個小時,留給了我一份雖然不完整,但是大小可觀的資料集,裡面有 235,532 個 WHOIS 記錄。這個資料集已經存在一年之久了,除了有點過時,應該還是有價值的。

$ ls -l複製程式碼
-rw-rw-r-- 1 mark mark  5946203 Mar 31  2016 part-00001
-rw-rw-r-- 1 mark mark  5887326 Mar 31  2016 part-00002
...
-rw-rw-r-- 1 mark mark  6187219 Mar 31  2016 part-00154
-rw-rw-r-- 1 mark mark  5961162 Mar 31  2016 part-00155複製程式碼

當我重點檢查那些爬到「robots.txt」的爬蟲機器人的 IP 所屬時,除了 Google,這六家公司也出現了很多次:Amazon、百度、Digital Ocean、Hetzner、Linode 和 New Dream Network。我跑了以下的命令,嘗試去取出它們的 IPv4 WHOIS 記錄。

$ grep -i `amazon`            part-00* > amzn
$ grep -i `baidu`             part-00* > baidu
$ grep -i `digital ocean`     part-00* > digital_ocean
$ grep -i `hetzner`           part-00* > hetzner
$ grep -i `linode`            part-00* > linode
$ grep -i `new dream network` part-00* > dream複製程式碼

我需要從以上六個檔案中,解析二次編碼的 JSON 字串,這些字串包含了檔名和頻率次數資訊。我使用了 iPython 程式碼來獲得不同的 CIDR 塊,程式碼如下:

import json


def parse_cidrs(filename):
    lines = open(filename, `r+b`).read().split(`
`)

    recs = []

    for line in lines:
        try:
            recs.append(
                json.loads(
                    json.loads(`:`.join(line.split(`	`)[0].split(`:`)[1:]))))
        except ValueError:
            continue

    return set([str(rec.get(`network`, {}).get(`cidr`, None))
                for rec in recs])


for _name in [`amzn`, `baidu`, `digital_ocean`,
              `hetzner`, `linode`, `dream`]:
    print _name, parse_cidrs(_name)複製程式碼

下面是一份清理完畢的 WHOIS 記錄例項,我已經去掉了聯絡資訊。

{
    "asn": "38365",
    "asn_cidr": "182.61.0.0/18",
    "asn_country_code": "CN",
    "asn_date": "2010-02-25",
    "asn_registry": "apnic",
    "entities": [
        "IRT-CNNIC-CN",
        "SD753-AP"
    ],
    "network": {
        "cidr": "182.61.0.0/16",
        "country": "CN",
        "end_address": "182.61.255.255",
        "events": [
            {
                "action": "last changed",
                "actor": null,
                "timestamp": "2014-09-28T05:44:22Z"
            }
        ],
        "handle": "182.61.0.0 - 182.61.255.255",
        "ip_version": "v4",
        "links": [
            "http://rdap.apnic.net/ip/182.0.0.0/8",
            "http://rdap.apnic.net/ip/182.61.0.0/16"
        ],
        "name": "Baidu",
        "parent_handle": "182.0.0.0 - 182.255.255.255",
        "raw": null,
        "remarks": [
            {
                "description": "Beijing Baidu Netcom Science and Technology Co., Ltd...",
                "links": null,
                "title": "description"
            }
        ],
        "start_address": "182.61.0.0",
        "status": null,
        "type": "ALLOCATED PORTABLE"
    },
    "query": "182.61.48.129",
    "raw": null
}複製程式碼

這份七個公司的列表不是一個關於爬蟲機器人來源的全面的列表。我發現,除了一個從世界各地連線的分散式爬蟲戰隊,很多爬蟲流量來源於一些在烏克蘭、中國的住宅 IP,源頭很難分辨。說實話,如果我想要一個全面的爬蟲機器人實用的 IP 列表,我只需要看看 HTTP 頭的順序,檢查下 TCP/IP 的行為,搜尋 偽造 IP 註冊(請看 28 頁),列表就出來了,並且這就像貓和老鼠的遊戲一樣。

安裝庫

對於這個專案而言,我會實用一些寫得很好的庫。Apache Log Parser 可以解析 Apache 和 Nginx 生成的流量日誌。這個庫支援從日誌檔案中解析超過 30 種不同型別的資訊,並且我發現,它相當彈性、可靠。Python User Agents 可以解析使用者代理的字串,並執行一些代理使用的基本分類操作。Colorama 協助建立有高亮的 ANSI 輸出。Netaddr 是一種成熟的、維護得很好的網路地址操作庫。

$ pip install -e git+https://github.com/rory/apache-log-parser.git#egg=apache-log-parser 
              -e git+https://github.com/selwin/python-user-agents.git#egg=python-user-agents 
              colorama 
              netaddr複製程式碼

爬蟲機器人監控指令碼

接下來的部分是跑 monitor.py 的內容。這段指令碼從 stdin(標準輸入) 管道中接收 web 流量日誌。這說明你可以通過 ssh 在遠端伺服器上看日誌,在本地跑這段指令碼。

我先從 Python 標準庫裡匯入兩個庫,並通過 pip 安裝了五個外部庫。

import sys
from urlparse import urlparse

import apache_log_parser
from colorama import Back, Style
import geoip2.database
from netaddr import IPNetwork, IPAddress
from user_agents import parse複製程式碼

接下來我設定好 MaxMind 的 geoip2 庫,以使用「GeoLite2-City.mmdb」城市級別的庫。

我還設定了 apache_log_parser,來處理儲存的 web 日誌格式。你的日誌格式可能不一樣,所以可能需要花點時間比較下你的 web 伺服器的流量日誌配置與這個庫的 格式文件

最後,我有一個我發現的屬於那七家公司的 CIDR 塊的字典。在這個列表裡,從本質上來說,百度不是一家主機或者雲提供商,但是跑著很多無法通過它們的使用者代理所識別的爬蟲機器人。

reader = geoip2.database.Reader(`GeoLite2-City.mmdb`)

_format = "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i""
line_parser = apache_log_parser.make_parser(_format)

CIDRS = {
    `Amazon`: [`107.20.0.0/14`, `122.248.192.0/19`, `122.248.224.0/19`,
               `172.96.96.0/20`, `174.129.0.0/16`, `175.41.128.0/19`,
               `175.41.160.0/19`, `175.41.192.0/19`, `175.41.224.0/19`,
               `176.32.120.0/22`, `176.32.72.0/21`, `176.34.0.0/16`,
               `176.34.144.0/21`, `176.34.224.0/21`, `184.169.128.0/17`,
               `184.72.0.0/15`, `185.48.120.0/26`, `207.171.160.0/19`,
               `213.71.132.192/28`, `216.182.224.0/20`, `23.20.0.0/14`,
               `46.137.0.0/17`, `46.137.128.0/18`, `46.51.128.0/18`,
               `46.51.192.0/20`, `50.112.0.0/16`, `50.16.0.0/14`, `52.0.0.0/11`,
               `52.192.0.0/11`, `52.192.0.0/15`, `52.196.0.0/14`,
               `52.208.0.0/13`, `52.220.0.0/15`, `52.28.0.0/16`, `52.32.0.0/11`,
               `52.48.0.0/14`, `52.64.0.0/12`, `52.67.0.0/16`, `52.68.0.0/15`,
               `52.79.0.0/16`, `52.80.0.0/14`, `52.84.0.0/14`, `52.88.0.0/13`,
               `54.144.0.0/12`, `54.160.0.0/12`, `54.176.0.0/12`,
               `54.184.0.0/14`, `54.188.0.0/14`, `54.192.0.0/16`,
               `54.193.0.0/16`, `54.194.0.0/15`, `54.196.0.0/15`,
               `54.198.0.0/16`, `54.199.0.0/16`, `54.200.0.0/14`,
               `54.204.0.0/15`, `54.206.0.0/16`, `54.207.0.0/16`,
               `54.208.0.0/15`, `54.210.0.0/15`, `54.212.0.0/15`,
               `54.214.0.0/16`, `54.215.0.0/16`, `54.216.0.0/15`,
               `54.218.0.0/16`, `54.219.0.0/16`, `54.220.0.0/16`,
               `54.221.0.0/16`, `54.224.0.0/12`, `54.228.0.0/15`,
               `54.230.0.0/15`, `54.232.0.0/16`, `54.234.0.0/15`,
               `54.236.0.0/15`, `54.238.0.0/16`, `54.239.0.0/17`,
               `54.240.0.0/12`, `54.242.0.0/15`, `54.244.0.0/16`,
               `54.245.0.0/16`, `54.247.0.0/16`, `54.248.0.0/15`,
               `54.250.0.0/16`, `54.251.0.0/16`, `54.252.0.0/16`,
               `54.253.0.0/16`, `54.254.0.0/16`, `54.255.0.0/16`,
               `54.64.0.0/13`, `54.72.0.0/13`, `54.80.0.0/12`, `54.72.0.0/15`,
               `54.79.0.0/16`, `54.88.0.0/16`, `54.93.0.0/16`, `54.94.0.0/16`,
               `63.173.96.0/24`, `72.21.192.0/19`, `75.101.128.0/17`,
               `79.125.64.0/18`, `96.127.0.0/17`],
    `Baidu`: [`180.76.0.0/16`, `119.63.192.0/21`, `106.12.0.0/15`,
              `182.61.0.0/16`],
    `DO`: [`104.131.0.0/16`, `104.236.0.0/16`, `107.170.0.0/16`,
           `128.199.0.0/16`, `138.197.0.0/16`, `138.68.0.0/16`,
           `139.59.0.0/16`, `146.185.128.0/21`, `159.203.0.0/16`,
           `162.243.0.0/16`, `178.62.0.0/17`, `178.62.128.0/17`,
           `188.166.0.0/16`, `188.166.0.0/17`, `188.226.128.0/18`,
           `188.226.192.0/18`, `45.55.0.0/16`, `46.101.0.0/17`,
           `46.101.128.0/17`, `82.196.8.0/21`, `95.85.0.0/21`, `95.85.32.0/21`],
    `Dream`: [`173.236.128.0/17`, `205.196.208.0/20`, `208.113.128.0/17`,
              `208.97.128.0/18`, `67.205.0.0/18`],
    `Google`: [`104.154.0.0/15`, `104.196.0.0/14`, `107.167.160.0/19`,
               `107.178.192.0/18`, `108.170.192.0/20`, `108.170.208.0/21`,
               `108.170.216.0/22`, `108.170.220.0/23`, `108.170.222.0/24`,
               `108.59.80.0/20`, `130.211.128.0/17`, `130.211.16.0/20`,
               `130.211.32.0/19`, `130.211.4.0/22`, `130.211.64.0/18`,
               `130.211.8.0/21`, `146.148.16.0/20`, `146.148.2.0/23`,
               `146.148.32.0/19`, `146.148.4.0/22`, `146.148.64.0/18`,
               `146.148.8.0/21`, `162.216.148.0/22`, `162.222.176.0/21`,
               `173.255.112.0/20`, `192.158.28.0/22`, `199.192.112.0/22`,
               `199.223.232.0/22`, `199.223.236.0/23`, `208.68.108.0/23`,
               `23.236.48.0/20`, `23.251.128.0/19`, `35.184.0.0/14`,
               `35.188.0.0/15`, `35.190.0.0/17`, `35.190.128.0/18`,
               `35.190.192.0/19`, `35.190.224.0/20`, `8.34.208.0/20`,
               `8.35.192.0/21`, `8.35.200.0/23`,],
    `Hetzner`: [`129.232.128.0/17`, `129.232.156.128/28`, `136.243.0.0/16`,
                `138.201.0.0/16`, `144.76.0.0/16`, `148.251.0.0/16`,
                `176.9.12.192/28`, `176.9.168.0/29`, `176.9.24.0/27`,
                `176.9.72.128/27`, `178.63.0.0/16`, `178.63.120.64/27`,
                `178.63.156.0/28`, `178.63.216.0/29`, `178.63.216.128/29`,
                `178.63.48.0/26`, `188.40.0.0/16`, `188.40.108.64/26`,
                `188.40.132.128/26`, `188.40.144.0/24`, `188.40.48.0/26`,
                `188.40.48.128/26`, `188.40.72.0/26`, `196.40.108.64/29`,
                `213.133.96.0/20`, `213.239.192.0/18`, `41.203.0.128/27`,
                `41.72.144.192/29`, `46.4.0.128/28`, `46.4.192.192/29`,
                `46.4.84.128/27`, `46.4.84.64/27`, `5.9.144.0/27`,
                `5.9.192.128/27`, `5.9.240.192/27`, `5.9.252.64/28`,
                `78.46.0.0/15`, `78.46.24.192/29`, `78.46.64.0/19`,
                `85.10.192.0/20`, `85.10.228.128/29`, `88.198.0.0/16`,
                `88.198.0.0/20`],
    `Linode`: [`104.200.16.0/20`, `109.237.24.0/22`, `139.162.0.0/16`,
               `172.104.0.0/15`, `173.255.192.0/18`, `178.79.128.0/21`,
               `198.58.96.0/19`, `23.92.16.0/20`, `45.33.0.0/17`,
               `45.56.64.0/18`, `45.79.0.0/16`, `50.116.0.0/18`,
               `80.85.84.0/23`, `96.126.96.0/19`],
}複製程式碼

我建立了一個工具函式,可以傳入一個 IPv4 地址和一個 CIDR 塊列表,它會告訴我這個 IP 地址是不是屬於給定的這些 CIDR 塊中的任何一個。

def in_block(ip, block):
    _ip = IPAddress(ip)
    return any([True
                for cidr in block
                if _ip in IPNetwork(cidr)])複製程式碼

下面這個函式接收請求( req )和瀏覽器代理( agent )的物件,並嘗試用這兩個物件來判斷流量源頭/瀏覽器代理是否來自爬蟲機器人。這個瀏覽器代理物件是使用 Python 使用者代理庫構造的,並且有一些測試用於判斷,使用者代理字串是否屬於某個已知的爬蟲機器人。我已經用一些我從庫的分類系統中看到的 token 來擴充套件這些測試。同時我在 CIDR 塊迭代,來判斷遠端主機的 IPv4 地址是否在裡面。

def bot_test(req, agent):
    ua_tokens = [`daum/`, # Daum Communications Corp.
                 `gigablastopensource`,
                 `go-http-client`,
                 `http://`,
                 `httpclient`,
                 `https://`,
                 `libwww-perl`,
                 `phantomjs`,
                 `proxy`,
                 `python`,
                 `sitesucker`,
                 `wada.vn`,
                 `webindex`,
                 `wget`]

    is_bot = agent.is_bot or 
             any([True
                  for cidr in CIDRS.values()
                  if in_block(req[`remote_host`], cidr)]) or 
             any([True
                  for token in ua_tokens
                  if token in agent.ua_string.lower()])

    return is_bot複製程式碼

下面是指令碼的主要部分。web 流量日誌從標準輸入裡一行行地讀入。內容的每一行都被解析成一個帶 token 版本的請求、使用者代理和被請求的 URI。這些物件讓與這些資料打交道變得更容易,不需要去麻煩地在空中解析它們。

我嘗試著用 MaxMind 的庫查詢與這些 IPv4 相關的城市和國家。如果有任何型別的查詢失敗,結果會簡單地設定為 None。

在爬蟲機器人測試後,我準備輸出。如果請求看起來是從爬蟲機器人處傳送的,它會被標成紅色背景,高亮在輸出上。

if __name__ == `__main__`:
    while True:
        try:
            line = sys.stdin.readline()
        except KeyboardInterrupt:
            break

        if not line:
            break

        req = line_parser(line)
        agent = parse(req[`request_header_user_agent`])
        uri = urlparse(req[`request_url`])

        try:
            response = reader.city(req[`remote_host`])
            country, city = response.country.iso_code, response.city.name
        except:
            country, city = None, None

        is_bot = bot_test(req, agent)

        agent_str = `, `.join([item
                               for item in agent.browser[0:3] +
                                           agent.device[0:3] +
                                           agent.os[0:3]
                               if item is not None and
                                  type(item) is not tuple and
                                  len(item.strip()) and
                                  item != `Other`])

        ip_owner_str = ` `.join([network + ` IP`
                                  for network, cidr in CIDRS.iteritems()
                                  if in_block(req[`remote_host`], cidr)])

        print Back.RED + `b` if is_bot else `h`, 
              country, 
              city, 
              uri.path, 
              agent_str, 
              ip_owner_str, 
              Style.RESET_ALL複製程式碼

爬蟲機器人檢測實戰

接下來是一個例子,在把這些內容放到監測指令碼時,我是用下面這種方式連線輸出 web 流量日誌的最後一百行的。

$ ssh server 
    `tail -n100 -f access.log` 
    | python monitor.py複製程式碼

有可能來源於爬蟲機器人的請求將使用紅色背景和「b」字首高亮。不存在爬蟲機器人的流量將被打上「h」的字首,代表 human(人)。下面是從指令碼出來的樣例輸出,不過沒有 ANSI 背景色。

...
b US Indianapolis /robots.txt Python Requests 2.2 Linux 3.2.0
h DE Hamburg /tensorflow-vizdoom-bots.html Firefox 45.0 Windows 7
h DE Hamburg /theme/css/style.css Firefox 45.0 Windows 7
h DE Hamburg /theme/css/syntax.css Firefox 45.0 Windows 7
h DE Hamburg /theme/images/mark.jpg Firefox 45.0 Windows 7
b US Indianapolis /feeds/all.atom.xml rogerbot 1.0 Spider Spider Desktop
b US Mountain View /billion-nyc-taxi-kdb.html  Google IP
h CH Zurich /billion-nyc-taxi-rides-s3-vs-hdfs.html Chrome 56.0.2924 Windows 7
h IE Dublin /tensorflow-vizdoom-bots.html Chrome 56.0.2924 Mac OS X 10.12.0
h IE Dublin /theme/css/style.css Chrome 56.0.2924 Mac OS X 10.12.0
h IE Dublin /theme/css/syntax.css Chrome 56.0.2924 Mac OS X 10.12.0
h IE Dublin /theme/images/mark.jpg Chrome 56.0.2924 Mac OS X 10.12.0
b SG Singapore /./theme/images/mark.jpg Slack-ImgProxy Spider Spider Desktop Amazon IP複製程式碼

相關文章