Stop Forum Spamを用いてロボットスパムを検挙してみる

初めまして。北海道ゆっくり放送 と申します（対海外向けのニックネームとしては HokkaidoPerson を使ってます。同一人物です）。
自己紹介が長くなるのもよろしくないので僕については[個人サイト](https://www.hkdyukkuri.space/)などをご覧頂ければと思います。

Stop Forum Spamという、ロボットスパムに対抗するフォーラムを使い始めたのですが、その日本語ドキュメントが全然無かったので、ここに雑記的な感じで書いていこうと思います。
PHPに関しては初心者ゆえ、良質でないソースコードを記載しているかもしれませんので、予めご了承願います。

# Stop Forum Spamって何？
[Stop Forum Spam](https://www.stopforumspam.com/)は、ロボットスパムに関する情報を収集し、それを検索したり、自分のサイト上で未知のスパムに対抗出来るようにしたりするサービスです。
完全無料で利用出来るありがたいサービスですが、サイト上では常時寄付を受け付けています。
トップページの「API QUERIES TO DATE」や「CURRENT API QUERIES PER SECOND」を見るに、かなり大規模なプロジェクトとして動いている模様です。

同サイトの利用方法について、英語での記述が充実しているので、英語が堪能な方は上のリンクを訪れて説明を読むといいと思います。

## 僕がこのフォーラムを使い始めた経緯
**※手っ取り早く使い方を知りたいという方は、この項目は読み飛ばしてもらって全然構いません。**

僕はとあるオープンコミュニティのWikiを運営しているのですが、当初はスパム対策に関してほぼ無知で、恥ずかしながら、スパム対策はほぼせずに運営して来ました。
そしてある時、Wikiのユーザー登録のイタズラが大量になされ、更に特定のページのコメント欄におびただしい数のスパムコメントが投稿されてしまいました…。
現在はちゃんと対策を講じ、イタズラ登録やスパムコメントが投稿されないようにしていますが、一度スパム行為に成功して味をしめてしまったためか、その後僕のサイトにはロボットによると思われるアクセスが引っ切り無しに来るようになってしまい、対策に頭を悩ませました（アクセスカウンターの数字が、特定のページだけ異様に伸びたりする）。
最終的にこのStop Forum Spamにたどり着き、それを導入した所、ロボットスパムからの大量のアクセスをほぼブロックする事に成功しました（それでも、報告実績の無い新生のロボットについてはあまりブロック出来ないようで、それについては手動でカウンターのログから除外しています）。

現在は、後述する方法でアクセスブロックとコメント報告を行なっています。特にコメント報告については、他のサイトで報告例が無かったようなスパムについても報告出来ていて、フォーラムの機能向上に少しばかり寄与出来ているのかなとも思いつつ、「完全にスパマーにナメられてんじゃねえか」とも思って複雑な気持ちであります (´・ω・｀)

# フォーラムのデータベースを使ってみる
今回ロボット対策するにあたって、Stop Forum Spam（めんどくさいのでここからはSFSって書きますね）を用いた、[DokuWiki](https://www.dokuwiki.org/)のプラグインをこしらえました。今はまだ作ってる途中なので、ある程度形になったらプラグインを一般公開しますね。

## APIを使う
SFSのAPIを使って色んな事が出来ます。
APIについては、[SFSの公式ドキュメント（英語）](https://www.stopforumspam.com/usage)に大体の事が書いてありますので見てみて下さい。

### ユーザーがスパムなのか検証してみる
ユーザーがスパムなのか検証するには、http://api.stopforumspam.org/api のAPIを使います。
例えば、http://api.stopforumspam.org/api?ip=91.186.18.61 にアクセスすると、IPアドレス 91.186.18.61がスパムなのかどうか検証してくれます。
http://api.stopforumspam.org/api?email=g2fsehis5e@mail.ru や、http://api.stopforumspam.org/api?username=MariFoogwoogy など、メールアドレスやユーザー名に関しても同様にチェックしてくれます。
上の例のようにGETメソッドでユーザー情報を渡しても良いですが、POSTメソッドで渡す事も出来ます。

早速ですが、以下、APIから生の返答データを受け取る関数です。

```php
    function rawdata($ip = null, $email = null, $username = null, $wildcards = null){
        // IPもメルアドもユーザー名も指定されていない場合は空の配列を返す
        if ($ip == null and $email == null and $username == null) return array();

        // https://www.stopforumspam.com/usage を一部参考にしました
        // setup the URL
        $data = array(
            'ip' => $ip,
            'username' => $username,
            'email' => $email,
        );

        $data = http_build_query($data, '', '&', PHP_QUERY_RFC3986);
        if ($wildcards != null) $data .= '&' . preg_replace('/[^a-z0-9&=]+/', '', $wildcards);

        // init the request, set some info, send it and finally close it
        $this->ch = curl_init();
        if ($this->ch) {
            curl_setopt ($this->ch, CURLOPT_URL, 'http://api.stopforumspam.org/api?json');
            curl_setopt ($this->ch, CURLOPT_POST, 1);
            curl_setopt ($this->ch, CURLOPT_POSTFIELDS, $data);
            curl_setopt ($this->ch, CURLOPT_RETURNTRANSFER, true);

            $result = curl_exec($this->ch);
            curl_close($this->ch);
        }
        // https://www.stopforumspam.com/usage からの引用終わり
        return json_decode($result, true);
    }
```

`wildcards`変数は、他の必要なワイルドカードを指定するスペースです。英数字と`&`、`=`以外の文字は自動的に除去されるようにしています。
データは、JSON形式で受け取ったのを配列に変換してreturnしています。この時指定するパラメータ`json`は、どうやらPOSTでは受け取れず、GET形式で渡す必要があるみたいです（僕がつっかかったポイントの一つ）。
http_build_query関数を使う際も気を付ける必要があるみたいで、僕のケースでは、デフォルトのPHP_QUERY_RFC1738形式だと、後述する「スパマー報告」の際にデータの受け渡しが上手く行きませんでした。PHP_QUERY_RFC3986だと上手く行くみたいです。


そして、そこからデータを受け取ってスパマーかどうかを検証する関数がこちら。

```php
    function confcheck($ip = null, $email = null, $username = null, $returnvalue = FALSE){
        // IPもメルアドもユーザー名も（ry
        if ($ip == null and $email == null and $username == null) return FALSE;

        $border = $this->getConf('confidenceBorder');  //スパマーと判断する基準の数字（スコアがこれ以上だとスパムと認識）の設定を読み込む
        if ($border == 0) return FALSE;  // 設定値が0の場合はチェックしない

        // 上の rawdata 関数経由で情報ゲット
        $resultarray = $this->rawdata($ip, $email, $username);

        // 情報取得に成功した場合は ipconf などの変数にその数値を代入、失敗の場合は-1を代入
        if (isset($resultarray['ip']['confidence'])) $ipconf = $resultarray['ip']['confidence']; else $ipconf = -1;
        if (isset($resultarray['email']['confidence'])) $emailconf = $resultarray['email']['confidence']; else $emailconf = -1;
        if (isset($resultarray['username']['confidence'])) $nameconf = $resultarray['username']['confidence']; else $nameconf = -1;
        if (isset($resultarray['ip']['frequency'])) $ipfreq = $resultarray['ip']['frequency']; else $ipfreq = -1;
        if (isset($resultarray['email']['frequency'])) $emailfreq = $resultarray['email']['frequency']; else $emailfreq = -1;
        if (isset($resultarray['username']['frequency'])) $namefreq = $resultarray['username']['frequency']; else $namefreq = -1;

        // returnvalue がTRUEの場合はデータをそのまま返す、そうでないならスパマーかどうかのふるいにかける
        if ($returnvalue) return array('ip' => $ipconf, 'email' => $emailconf, 'username' => $nameconf);

        if ($ipconf >= $border or $emailconf >= $border or $nameconf >= $border) {
            $logfilename = $this->getConf('logPlace');  // ログ保存場所（主にデバッグ）
            if ($logfilename == '') return TRUE;
            if ($loghandle = fopen($logfilename, 'a')) {
                $logcontent = "=== " . date('H:i:s M d, Y') . " - higher confidence score than the border ===\n";
                if ($ip != '') $logcontent .= "IP: " . $ip .", frequency " . $ipfreq . ", confidence " . $ipconf . "\n";
                if ($email != '') $logcontent .= "E-mail Address: " . $email .", frequency " . $emailfreq . ", confidence " . $emailconf . "\n";
                if ($username != '') $logcontent .= "User Name: " . $username .", frequency " . $namefreq . ", confidence " . $nameconf . "\n";
                $logcontent .= "\n";
                fwrite($loghandle, $logcontent);
                fclose($loghandle);
            }
            return TRUE;
        } else return FALSE;
    }
```

ここでチェックしているのは Confidence Score （以下 CS とします）という値で、当該ユーザーがスパマーかどうかを百分率で表示するものです。スパム行為が確認された最後の日付と、累計の報告回数をもとに統計的に算出されるとの事。ここでは`ipconf`などの変数に代入しています。
同時に、`ipfreq`などの変数にも値を代入していますが、ここで取得しているのは Frequency Score （以下 FS とします）というもので、累計の報告回数を示します。ここでは直接的なチェックには使っていませんが、ログ保存用に一応取得しています。

この関数では、変数`returnvalue`がTRUEの場合は、取得したCSをそのまま配列で返します。そうでない場合は、事前に設定された基準値をもとに、スパマーかどうかのふるいにかけ、スパマーならTRUEを、そうでないならFALSEを返します。


この関数を使って、ユーザーのIPアドレスを検証してスパマーだった場合はアクセスを完全に遮断するようにしました。すると…！

```
=== 20:57:41 Dec 15, 2018 - higher confidence score than the border ===
IP: xxx.xxx.xxx.xxx, frequency 8, confidence 64

=== 21:16:25 Dec 15, 2018 - higher confidence score than the border ===
IP: xxx.xxx.xxx.xxx, frequency 4, confidence 47.06

=== 21:47:11 Dec 15, 2018 - higher confidence score than the border ===
IP: xxx.xxx.xxx.xxx, frequency 8, confidence 64

......
```

あれよあれよという間に大量のスパムがブロックされていくーーーーーーー！（上のログデータではIPアドレスを伏せています）
カウンターの数字もある程度スッキリして嬉しい反面、「こんなにも大量のロボットに目を付けられてしまっているのか…」と、ロボット対策を怠っていた過去をちょっと後悔しました \_(:3」∠)_
現在でも、アクセスブロックはコンスタントに続いている模様です。

### スパマーによるものと判明したコメントを自動報告
現在、スパムコメント自体は、Google reCAPTCHAやNGワードリスト（"a href"といった語の他、日本語のみのコミュニティなら"download"とかの普遍的な語もブロックするといいみたいです）のおかげで未遂に終わっていますが、「未遂に終わったスパムコメントも報告した方がいいのでは」と思い、「100%ロボットによる仕業だ」と判断出来たコメントを自動報告するようにしました[^1]（少し前までは手動報告してた）。
そのために作った関数がこちら。

```php
    function addToDatabase($ip = null, $email = null, $username = null, $evidence = null){
        if ($ip == null or $email == null or $username == null) return;
        $api = $this->getConf('reportAPI');  // APIキーの設定を取得
        if ($api == '') return;  // 未設定ならさようなら

        // https://www.stopforumspam.com/usage を一部参考にしました
        $data = array(
            'username' => $username,
            'ip_addr' => $ip,
            'evidence' => $evidence,
            'email' => $email,
            'api_key' => $api
        );

        $data = http_build_query($data, '', '&', PHP_QUERY_RFC3986);

        $this->ch = curl_init();
        if ($this->ch) {
            curl_setopt ($this->ch, CURLOPT_URL, 'https://www.stopforumspam.com/add.php');
            curl_setopt ($this->ch, CURLOPT_POST, 1);
            curl_setopt ($this->ch, CURLOPT_POSTFIELDS, $data);
            curl_setopt ($this->ch, CURLOPT_RETURNTRANSFER, true);
            $result = curl_exec ($this->ch);
            $detail = curl_getinfo($this->ch);
            curl_close ($this->ch);

            if ($detail['http_code'] == '200') return array('succeeded' => TRUE, 'message' => $this->getLang('submitted')); else return array('succeeded' => FALSE, 'message' => $this->getLang('errorHappened') . strip_tags($result));  //getLang関数は、エラーメッセージなどの各言語版（日本語で利用中なら日本語のメッセージ、英語なら英語のメッセージ）を取得するものです

        } else return array('succeeded' => FALSE, 'message' => $this->getLang('curlError'));
    }
```

この関数を使うにはAPIキーを取得する必要があります。SFSにユーザー登録して、サイトのURLを登録します。APIキーはサイトごとに発行される模様です。
`evidence`は任意の変数で、コメントのログをここに代入しておくと、あとでURLスキャンサービスの提供に使われるらしいです（よくわからん）。

APIから返って来たHTTPコードが200の場合は報告成功、そうでないなら失敗です。失敗なら、どんなエラーが起きたのかも返って来ます。


現在、この関数をシステムに組み込んでいますが、ちゃんと自動報告されてるみたいです。わざわざログを開いて報告する負担が減ってよかったです。

[^1]: どうやってスパムロボットかそうでないかを判断しているかというと、当該Wikiのコメント欄にはメールアドレスを設置していない（改造してコメントアウトしている）のですが、ロボットが入力したコメントログを見ると、メールアドレス欄を入力した形跡があります（メソッドの'mail'にメールアドレスが残っていて、通常の非ログインユーザーであればこうなる事は無い）。それを根拠にして、「非ログインユーザーであり、かつメールアドレス欄を入力した形跡がある」場合にスパマーだと判断しています。

# 最後に
初めてQiitaの記事を書いてみましたが、どうでしょうか？
もし何か意見とかありましたら、コメント欄とかで言って下さると幸いです。

最後までお読み頂きありがとうございました。