Scrapyを使って自社SNSに特定形式の文字列が含まれていないかチェックする方法

ogp

こんにちは、ジャポニカ学習帳の表紙に昆虫が戻って来た1ことに喜んでいる、SRE部エンジニアの塩崎です。

先日、有名な投稿型メディアプラットフォームで投稿者のIPアドレスが漏洩するという事象が発生しました。我々ZOZOテクノロジーズが開発・運用しているWEARも、ユーザー投稿型のサービスであるという意味では同様であり、もしかしたら投稿者のIPアドレスを漏洩しているかもしれません。

本記事ではWEARがIPアドレス漏洩をしていないかどうかをクローリングで調査する手法、及びその結果問題がなかったということをお知らせします。

WEARで行われているセキュリティ対策

WEARで行われているセキュリティ対策の一部についても簡単に説明します。WEARでは専門家による定期的なセキュリティ診断を行い、そのレポートに基づいたよりセキュアになるための修正を継続的に行っております。

また、リリースされるコードはチーム内でコードレビューを行い、機能要件のみならずセキュリティの担保やコードの可読性などの非機能要件に関する議論も活発です。

さらに、他社と合同で行っているセキュリティ演習に積極的に参加している開発メンバーもおります。

このようにユーザーさんに安心してWEARを使って頂くために様々な対策を行っております。

とはいえ、厳重な対策を行っていてもミスを完全にゼロにすることは難しいです。今回は既に行っている対策とは少し毛色の異なる、クローリングという、より直接的な方法でIPアドレス漏洩の有無を確認してみました。

Scrapy

まず、今回の調査で使用したクローリング用のフレームワークであるScrapyを紹介します。ScrapyはScrapinghub社によって開発されているOSSです。クローリングの処理をPythonで書くため自由度が高く、また同社によって開発されているヘッドレスブラウザのSplashとの統合も容易などの特徴を持っています。作成したクローラーは自前のインフラで運用することも、同社の運用しているPaaSであるScrapy Cloud上で動かすこともできます。

scrapy.org

特徴

続いてScrapyの特徴を紹介します。

CSSセレクターやXPathで要素を抽出

HTMLから要素を抽出するための機能であるCSSセレクターやXPathが搭載されています。また、抽出された要素に対して正規表現でテキスト処理を行うことも容易です。

docs.scrapy.org

<html>
 <head>
  <base href='http://example.com/' />
  <title>Example website</title>
 </head>
 <body>
  <div id='images'>
   <a href='image1.html'>Name: My image 1 <br /><img src='image1_thumb.jpg' /></a>
   <a href='image2.html'>Name: My image 2 <br /><img src='image2_thumb.jpg' /></a>
   <a href='image3.html'>Name: My image 3 <br /><img src='image3_thumb.jpg' /></a>
   <a href='image4.html'>Name: My image 4 <br /><img src='image4_thumb.jpg' /></a>
   <a href='image5.html'>Name: My image 5 <br /><img src='image5_thumb.jpg' /></a>
  </div>
 </body>
</html>

上記のHTMLソースに対して、XPathと正規表現を用いてパースを行った例を以下に示します。

>>> response.xpath('//a[contains(@href, "image")]/text()').re(r'Name:\s*(.*)')
['My image 1',
 'My image 2',
 'My image 3',
 'My image 4',
 'My image 5']

対話的なインタフェース

IPythonのようなREPLが標準で搭載されており、CSSやXPathの検証を対話的に行えます。クローラーの実行中に scrapy.shell.inspect_response 関数を呼び出すことで、そこにブレークポイントを仕込みREPLを起動することもできます。Rubyでの開発中に binding.pry でREPLを起動できることに似ています。

docs.scrapy.org

数多くのデータフォーマット、数多くのストレージに対応

JSON・CSV・XMLなどの数多くのデータフォーマットに対応し、またローカルファイル・FTP・Amazon S3などのストレージに対応しています。

docs.scrapy.org

文字コード自動判定

UTF-8以外の文字コードにも対応できます。日本で使われているEUC-JPやShift JISにも対応できます。

Middlewareなどを使ってプラグイン的に処理を拡張可能

以下のページにScrapyのアーキテクチャ、及び処理の流れが書かれています。ENGINE SPIDER間、ENGINE DOWNLOADER間にMIDDLEWAREという紺色のコンポーネントがあります。これはスクレイピングやダウンロードの処理の前後に特定の処理を挟むことができる機能です。RubyのRack Middlewareを使ったことがある人ならばそのイメージが湧きやすいと思います。

docs.scrapy.org

例えば以下のような機能を提供するMiddlewareが標準で搭載されており、必要に応じで組み込むことができます。

  • リダイレクト時にCookieを保持する
  • Basic認証を行う
  • 同一URLに対するレスポンスをキャッシュする

WEARをクローリングしてみた

ここからは、実際にScrapyを使ってWEARをクローリングしてみます。皆さんがクローリングする際には、自分自身で運営しているサイトか許可を得たサイトでのみ行ってください。

Scrapyのインストール

まずはScrapyのインストールを行います。Scrapyは多くのPythonのライブラリと同様にpipでインストールできます。

pip install scrapy

人によっては環境分離ツールとしてPipenvやpoetryを使いたい方もいるかと思いますので、そこはご自由にどうぞ。

プロジェクト作成

インストール後、最初に行うことはプロジェクトの作成です。これによってクローリングに必要な多くのファイルが自動生成されます。Ruby on Railsにおける rails new に相当するものです。

scrapy startproject wear_crawler

スパイダー作成

次にスパイダーの作成を行います。Scrapyでは特定のサイトをクローリングするための方法を定義するためのクラスをスパイダーと呼んでいます。スパイダーの中にページのパース処理や、次のページを辿る処理などを記述します。以下のコマンドを実行することでひな形が生成されるので、それを元に処理を記述していきます。

scrapy genspider wear wear.jp

いきなりですが、完成したものがこちらになりますので、これを使って説明していきます。

import scrapy
import re

class WearSpider(scrapy.Spider):
    name = 'wear'

    start_urls = ['https://wear.jp/']
    allowed_domains = ['wear.jp’']

    def parse(self, response):
        ip_addresses = re.findall(r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}', response.text)
        yield {
            'url': response.url,
            'ip_addresses': ip_addresses,
        }

        urls = response.xpath('//a/@href')
        yield from response.follow_all(urls, callback=self.parse)

WearSpiderの先頭部分でいくつかのクラス変数の初期化を行っています。

nameはこのスパイダーの名前を表しており、コマンドラインからクローリング処理を行う時にスパイダーを指定するために使用します。

start_urlsはクローリング処理の起点となるURLのリストです。起点となるURLがDBに入っている場合などの動的な処理をしたい場合は start_requests メソッドを代わりに実装し、その中で複雑な処理を記述することもできます。

allowed_domainsはクローリング対象のドメインを表し、ここで指定したドメイン以外へのリクエストは自動的にスキップされます。リンクを辿る時に毎回チェックしても良いのですが、それは煩雑なためここで指定しています。

parseメソッドはクローリング処理を書く場所です。最初に response.text で取得できるページのHTMLソースから正規表現でIPアドレスと疑わしき文字列を取得しています。厳密にはこの正規表現ではIPアドレス以外の文字列も抽出してしまいます。ですが、False Negativeが増えることはないので、ここでは厳密性よりも分かりやすさを重視しています。そして、その結果をyieldを使いScrapy側に返しています。yieldに辞書型のオブジェクトを渡すことで、このオブジェクトがScrapyのデータ保存コンポーネントに渡されます。そこでシリアライズが行われ、CSV・JSONLなどのフォーマットに変換後、ファイルとして保存されます。

その後、XPathを使ってHTMLソースコード中の全てのaタグのhref属性を取得しています。その結果に対してresponse.follow_allを呼び出すことで、これらのリンクの全てを辿るジェネレーターを生成し、yield fromでそれの委譲を行っています。

HTTPリクエストが完了するとcallbackで指定したメソッドが呼ばれます。ここで自分自身であるself.parseメソッドを指定しているため、再帰的に処理が行われます。この時urlsにはwear.jp以外のドメインへのリンクも含まれますが、allowed_domainsにwear.jpのみを指定しているため、それらへのリクエストは自動的に排除されます。

yield fromに親しみのない方のために、動作がイメージしやすい同等の処理をするコードも以下に示します。

for url in urls:
    yield response.follow(url, callback=self.parse)

yieldを使っていたりcallbackを指定したりしているため、カンの良い方はお気づきかと思いますが、HTTPリクエストは非同期的に行われています。内部的にはTwistedを使ったI/O多重化を行っています。そのため、スパイダーの内部でブロッキングI/Oを呼び出すと、パフォーマンスが低下するので注意が必要です。

twistedmatrix.com

クローリングの実行

では、クローリングを実行してみます。以下のコマンドを実行するとクローリングを行い、その結果をresult.jlにJSONL形式で出力します。

scrapy crawl wear --output=result.jl

ログを確認すると、WEARのトップページからリンクを辿り、それらの中にIPアドレスらしき文字列が含まれているかどうかをチェックしていることが分かります。今回のクローラーはWEARの全ページを辿るため、処理が完了するまで非常に時間がかかります。そのため、適当なタイミングで Ctrl-C を押して処理を止めましょう。

クローラーの改善

とりあえず動くものができましたが、いくつか改良してみようと思います。

幅優先探索

Scrapyのデフォルト設定ではクロール予定のURLをLIFOのスタックに積み、深さ優先で探索を行います。より色々な種類のページをクロールするために、これを幅優先探索に切り替えます。DEPTH_PRIORITYで階層の浅いページのクローリングを優先するとともに、クロール予定のURLを格納するデータ構造をLIFOからFIFOに切り替えています。

DEPTH_PRIORITY = 1
SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleFifoDiskQueue'
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.FifoMemoryQueue'

docs.scrapy.org

リクエスト頻度の自動調整

次にリクエスト頻度の自動調整を行います。Scrapyにデフォルトで搭載されているAutoThrottle Extensionを使うことで、リクエスト頻度を動的に変えることができます。このExtensionは目標の並列度とダウンロードにかかった時間から最適なリクエスト頻度を計算して、クローリング中に動的にリクエスト頻度を変更します。

AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 5
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1
AUTOTHROTTLE_DEBUG = True

docs.scrapy.org

結果

改良後のクローラーを起動し数時間放置すると、チェックしたURLとそこに含まれるIPアドレスと思われる文字列のリストがJSONL形式で溜まっていきます。数万URLのスキャンが完了したタイミングで一旦クローラーをストップさせて結果を確認します。

その結果、いくつかのページで正規表現にマッチする文字列が見つかりましたが、どれも投稿者のIPアドレスではありませんでした。

cat result.jl | grep -v '\[\]'

例えばとあるコーデ一覧のページから ***.***.1.2 という文字列(一部伏せ字)が発見されました。念のためにHTMLソースを確認したところ、画像のalt属性に ***.****.1.2 という文字列が見つかりました。このalt属性はユーザー名やアイテム名などから自動生成されるものであり、更に調査したところユーザーが自分自身のニックネームとして ***.***.1.2 を設定していることが分かりました。

他にもいくつかのページでIPアドレスらしき文字列が見つかりましたが、いずれも上記の例と同様なケースでした。

まとめ

WEARのHTMLソースコード中にIPアドレスが含まれていないことをクローリングで確認しました。日頃から行っている、脆弱性診断やコードレビューとは少し違った方法でIPアドレスが漏洩していないことを直接的に確認できました。

ZOZOテクノロジーズでは、他社でセキュリティインシデントが発生した時に、それを対岸の火事と捉えずに自分たちのシステムを内省できる人材を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

tech.zozo.com

カテゴリー