ChromeExtension + Serverless でフィッシングサイトをブロックするツールを作ってみたお話

## はじめに

今日(2015/12/16)から冬休みという方も多いのではないのでしょうか?

冬休みの間 serverless を触りたい！ひまだひまだ！！！！
という方は [シズオカアプリコンテスト](https://shizuoka-app-awards.jp/) に参加してみては
いかがでしょうか?

参加申し込みをするだけで **$50分のクーポン** が貰えますよ！

今回作ったアプリケーションもこの[シズオカアプリコンテスト](https://shizuoka-app-awards.jp/)の出品作品です。

なお、リポジトリは以下で公開しています。(PRお待ちしております)
https://github.com/kengos/is_it_phishing

## フィッシングサイトをブロックしたい

じぶん銀行 では 12/21 にこんな注意喚起をしています。

>不審なメールのリンク先には絶対にアクセスしないようご注意ください。
万が一、不審なメールのリンク先にアクセスしてしまった場合、偽サイトでパスワードや確認番号表の数字の入力を求められても絶対に入力しないでください。
>http://www.jibunbank.co.jp/announcement/2015/1221_01.html

そんなこと言われても、自分自身の注意だけだと限界がありますよね。

どうせなら機械的にブロックできる仕組みを用意したいなと考えました。

## どうやってブロックするのか?

(日本を対象とした)フィッシングサイトをブロックするには以下の方法が良さそうだなと考えました。

* whois の登録情報中に CN, TWなどが含まれている
* whois の登録情報の メールアドレスが フリーのメールアドレスだ
* google に インデックスされていない(違法サイトなので当然)
* 中国の無料のアクセス解析ツールが仕込まれている(51.la)
* 文章中の日本語が怪しい

今回は「文章中の日本語が怪しい」以外を組み込んだアプリを作ってみることにしました。
(めんどくさい + サンプル探すのが大変)

また、DNS汚染などでの被害もあるようなので、
別環境から正引きしたIPアドレスとクライアントがアクセスしているIPアドレスが同一かどうかの
チェックも含めてみることにしました。

こんなイメージとなります

![qiita_1.png](https://qiita-image-store.s3.amazonaws.com/0/14258/e6a25496-b760-e165-8e18-e4822a8e545c.png)

# 技術的なお話

## ChromeExtensionの仕組み

![qiita_2.png](https://qiita-image-store.s3.amazonaws.com/0/14258/b86b83e2-1193-a985-e067-096fb305eb65.png)

* [content/main.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/content/main.js)
* [background/main.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/background/main.js)

### content/main.js

クライアントがHTMLを読み込むと以下が実行され, background/main.js に対してメッセージが送信されます。

```js:content/main.js
chrome.runtime.sendMessage({
  location: window.location,
  // scripts には HTML中のscriptタグ, iframeタグの src属性の中身が入っている
  scripts: scripts.concat(iframes)
});
```

### background/main.js

background/main.js は content/main.jsが送ってくるメッセージを以下のように待ち受けています

メッセージを受信すると
1. ScoreBoard というクラスに対して送信されてきた `request`オブジェクトを渡します
2. ScoreBoard の結果を計算します(非同期)
3. ScoreBoard が完了した場合に `chrome.tabs.sendMessage` で送信してきたタブ(`sender.tab.id`)に対して結果を返します

```js:background/main.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  var board = new ScoreBoard(request);
  board.calculate();
  var timer = setInterval(function() {
    if(board.complete()) {
      clearInterval(timer);
      chrome.tabs.sendMessage(sender.tab.id, {request: request, sender: sender, score: board.getScore(), messages: board.getMessages()});
    }
  }, 100);
});
```

### クライアント側のHTML書き換え

content/main.js は以下でbackground/main.js が送信してくるメッセージを待ち受けています

```js:content/main.js
chrome.runtime.onMessage.addListener(function(response, sender, sendResponse) {
  console.log(response);
  if (response.score < 80) {
    return;
  }

  replaceDocument();
  // Document書き換え
  var ulTag = document.querySelector('#phishing-injection-messages');

  response.messages.forEach(function(message) {
    var liTag = document.createElement('li');
    liTag.innerHTML = message;
    ulTag.appendChild(liTag);
  });
});
```

`response` は以下の形式のデータが入っています。(これは実際のフィッシングサイトで得られた結果です)

```js
{
  score: 80,
  messages: [
    "js.users.51.la の javascript/iframe が呼び出されています。",
    "Googleの検索エンジンに登録されていません。"
  ]
}
```

`score`が 80以上の場合はイカの画像のように messagesの中身とともに画面に表示します。
(既存のHTMLは全て非表示にしています)

<img width="837" alt="スクリーンショット 2015-12-26 21.30.24.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/bfa9d838-8b6c-e7f1-174e-b007b0f7bbab.png">

## script, iframe の `src=xxxx` に対して行う処理

この処理では 該当ページ中に含まれている `script src=xxxx`, `iframe src=xxxx` のすべての値を
クライアント側から送信してもらい、それらをブラックリスト方式で判定しています。

実装箇所: [background/hostname_validator.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/background/hostname_validator.js)

受け取った src/iframe 中の src の配列が `hostnames` です

RegExpを使用して、`test`をしているだけです。

ブラックリストに含まれていた場合は該当の hostname を `this.errorHosts`に格納しています

`this.buildResult()` で スコア と クライアント側に表示するメッセージを生成しています

```js
validate: function(hostnames) {
  var reg = new RegExp(HostnameValidator.ERROR_HOSTS.join('|'));
  this.errorHosts = [].filter.call((hostnames || []), function(hostname) {
    return reg.test(hostname);
  });
  this.buildResult();
  return this.errorHosts.length == 0;
}
```

## whois 処理

AWS API Gatewayとの通信が発生する部分です

### ChromeExtension側の処理

実装箇所: [background/whois_validator.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/background/whois_validator.js)

Ajaxを利用して APIGatawayと通信をしています。
APIGataway側からのレスポンスを元に クライアント側に送信するスコア, メッセージを生成します

```js:whois_validator.js
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: WhoisValidator.API_END_POINT,
    data: {'hostname': hostname},
    dataType: 'json'
  }).done(function(json) {
    self.setCache(hostname, json);
    self.response = json;
  }).fail(function(json) {
    self.response = null;
  }).always(function() {
    self.buildResult();
  });
}
```

### AWS APIGateway, AWS lambda 側の処理

APIGateway/lambda: [dns/whois](https://github.com/kengos/is_it_phishing/tree/master/phishing-app/back/modules/dns/whois)

ビジネスロジック: [dns/lib/whois.js](https://github.com/kengos/is_it_phishing/blob/master/phishing-app/back/modules/dns/lib/whois.js)

外部ライブラリとして `node-whois`, `change-case` を利用しています

`event.hostname` に `/dns/whois?hostname=xxxx` の `xxxx`の部分が設定されてきますので、
その値を `whois.lookup` で検索しています

この結果は通常のコンソールで行う `whois` と同様の結果が返ってきますので、適当に整形してjson形式でレスポンスを返します

レスポンスを返す際には `return cb(null, {})` とすれば良いらしく、
`{}`の箇所に返したい任意のjson文字列をいれるだけでした

```js
module.exports.respond = function(event, cb) {
  if(event.hostname === '') {
    return cb(new Error("hostname is required"), null);
  }

  whois.lookup(event.hostname, function(err, whoisText) {
    if (err) {
      return cb(err, null);
    }
    var response = {};

    if(event.hostname.match(/\.jp$/)) {
      response['type'] = 0;
      response['text'] = whoisText;
    } else {
      var lines = whoisText.split(/\r\n|\r|\n/).filter(function(line, index) {
        return String(line).trim().match(': ');
      });
      lines.forEach(function(line) {
        var parts = line.split(": ");
        var key = changeCase.camelCase(parts[0]);
        response[key] = parts[1];
      });
    }

    return cb(null, response);
  });

};
```

## DNS正引き 処理

### ChromeExtension側の処理

実装箇所: [background/ip_validator.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/background/ip_validator.js)

Ajaxを利用して APIGatawayと通信をしています。
APIGataway側からのレスポンスを元に クライアント側に送信するスコア, メッセージを生成します

```js:ip_validator.js
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: IpValidator.API_END_POINT,
    data: {'hostname': hostname},
    dataType: 'json'
  }).done(function(json) {
    self.setCache(hostname, json);
    self.response = json;
  }).fail(function(json) {
    self.response = null;
  }).always(function() {
    self.buildResult(hostname);
  });
}
```

### APIGateway, lambda側の処理

APIGatewayの処理実装箇所: [whois/ip](https://github.com/kengos/is_it_phishing/tree/master/phishing-app/back/modules/dns/ip)

lambda(ビジネスロジック): [dns/lib/ip.js](https://github.com/kengos/is_it_phishing/blob/master/phishing-app/back/modules/dns/lib/ip.js)

nodeの標準機能で hostname => ipaddress変換ができるので、以下のようになります

```js:ip.js
var dns = require('dns');

module.exports.respond = function(event, cb) {
  if(event.hostname === '') {
    return cb(new Error("hostname is required"), null);
  }

  dns.resolve(event.hostname, function (err, addresses) {
    if (err) {
      return cb(err, null);
    }

    return cb(null, addresses);
  });
};
```

呼び出すと以下のようなレスポンスが返ってきます

```shell-session
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 18
Connection: keep-alive
Date: Sun, 27 Dec 2015 03:52:56 GMT
x-amzn-RequestId: 506bf61b-ac4d-11e5-95e3-63eadd8da545
X-Cache: Miss from cloudfront
Via: 1.1 61d0a31bb18a85e2e5a9c2cc54beb3e5.cloudfront.net (CloudFront)
X-Amz-Cf-Id: BKLP2gP0ls54Y8Rcw7xZ3mMu216AfErzrxc7_v-0XUYC9PZJkvqqiw==

["216.58.220.195"]
```

`cb(new Error('hostname is requires'), null)`が実行されると次のようなメッセージになります

```shell-session
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 213
Connection: keep-alive
Date: Sun, 27 Dec 2015 03:54:16 GMT
x-amzn-RequestId: 7fc898b0-ac4d-11e5-b91c-ab7dacc15141
X-Cache: Miss from cloudfront
Via: 1.1 65fe4947494affb6443dd314059f54c8.cloudfront.net (CloudFront)
X-Amz-Cf-Id: a-4so43Jp1j9iIuDoSUma1mScWIvgGDWKEGgwh4v6Fn03NtFoDLk-w==

{"errorMessage":"hostname is required","errorType":"Error","stackTrace":["Object.module.exports.respond (/var/task/modules/dns/lib/ip.js:5:15)","module.exports.handler (/var/task/modules/dns/ip/handler.js:19:7)"]}
```

## Googleへの検索リクエスト

lambda側で処理を書こうかなとも思ったのですが、リクエストIPを分散したほうがよさ気だったので
クライアント側からリクエスト送信をしています

[background/google_validator.js](https://github.com/kengos/is_it_phishing/blob/master/chrome_addon/app/background/google_validator.js)

(Deprecatedな)GoogleSearchApiを利用しています

検索クエリを `site:hostname` とすることで インデックスに登録されているかを調べています

```js:google_validator.js#lookup
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: 'http://ajax.googleapis.com/ajax/services/search/web?v=1.0&q=site:' + hostname,
    dataType : "json"
  }).done(function(response){
    self.setCache(hostname, response);
    self.response = response;
  }).fail(function(response){
    self.response = null;
  }).always(function() {
    self.buildResult();
  });
}
```

## AWS serverless(APIGateway/Lambda) について

### Servelessのデプロイ

`serveless function deploy`: Lambdaのデプロイを行います
`serveless endpoint deploy`: APIGatewayのデプロイを行います

deployするfunction, endopointを選択し `Deploy`を選択するとDeployできます

なお、`-a`オプションですべてのデプロイができ `-s [stage名]`で指定したstageのfunction/endpointのデプロイが出来ます

(今回はdevelopment stageのみしか用意していないので聞かれていません)

![s.gif](https://qiita-image-store.s3.amazonaws.com/0/14258/1f78d87e-0638-0ffc-2895-56ebeab76711.gif)

### AWSの料金は?

開発時点ではこんな感じのリクエスト数でした

<img width="426" alt="aws_1.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/fe9cf01f-54e8-7b3f-5b46-49f4f2289569.png">

### serverlessを使った際にAWS上で確認するコンソールは?

* Cloud Formation: serverlessの構成管理
* AWS Lambda: APIGatewayから呼び出されるビジネスロジック郡
* API Gateway: 外部とのインターフェース
* S3: 実態ファイル
* CloudWatch: AWS Lambdaのログなどはここから
* Billing
* IAM

### S3

serverless.[region].[app名]/Serverless/[app名]/[stage] 以下に配置されます

<img width="496" alt="aws_2.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/114d9e71-1d71-460e-7bfa-45dbc657b1b7.png">

lambdas以下にはLambdaのFunctionがRevisionごとに配置されます

<img width="219" alt="aws_s3.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/fef38482-1d7b-ccba-a4fc-0cca90347411.png">

### AWS Lambda

Functionごとに以下のように配置されます

<img width="796" alt="aws_3.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/fcebd593-d88d-0f98-d2ea-eb372586bcdd.png">

各Functionの中身はRevision管理され以下のようになります

<img width="1416" alt="aws_4.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/06e7de35-fc3c-e080-94dc-f76dd6700201.png">

### AWS APIGateway

<img width="712" alt="aws_6.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/99999552-2212-dad3-8edc-0409dab630b9.png">

### CloudWatch

<img width="370" alt="aws_5.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/2804b2a5-b080-bdb7-04e2-1fddd6b483f2.png">

以下は `cb(new Error('hostname is required'), null)` の際のログです

<img width="1100" alt="aws_7.png" src="https://qiita-image-store.s3.amazonaws.com/0/14258/d16bfbdc-d176-bbcb-4689-eed43408adeb.png">

### serverless でハマったこと

#### リクエストパラメータのもらい方

各々のAPIは次のように受け取ることにしました。

whois: `/dns/whois?hostname=xxxx`
DNS正引き: `/dns/ip?hostname=xxxx`

`hostname=xxxx` の設定をどのようにすればいいのかにハマりました。

結論的には以下の設定を入れてdeployするだけでした。

```json:s-function.json
"requestParameters": {
  "integration.request.querystring.integrationQueryParam": "method.request.querystring.hostname"
},
"requestTemplates": {
  "application/json": "{\"hostname\":\"$input.params('hostname')\"}"
},
```

```json:event.json
{
  "whois": {
    "hostname": "$input.params('hostname')"
  }
}
```

## 最後に(Help me!)

◯ whois について .comなどはうまく取れるのですが取れないものも多く困っています
予算的な都合で公開API等は使いたくないのでなにかいい方法を知っている方教えて下さい。
◯文書内の怪しい日本語チェックをしてみたいです。詳しい人(ry


かなり雑な作りですが、以下からダウンロードできます($5払いました...)

https://chrome.google.com/webstore/detail/%E3%83%95%E3%82%A3%E3%83%83%E3%82%B7%E3%83%B3%E3%82%B0%E3%82%B5%E3%82%A4%E3%83%88%E3%82%92%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF%E3%81%99%E3%82%8B%E3%83%84%E3%83%BC%E3%83%AB%E4%BB%AE/mhkdeobnhdhcgeklilgplkadcblehdja?hl=ja