ClamAVでアップロードファイルをウィルス対策する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417736/21434737-9d2f-24f0-f7b7-68bff788efcc.png" alt="clamav" width="80">



ClamAV(clamd)を使って、アップロードファイルをサーバーサイドでウィルススキャンするためのアーキ

> ![image 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417736/2c65ba7b-6ed3-0add-9f70-59a86d28654d.png)

## ClamAV

- マルチプラットフォームで**フリー**のアンチウィルススイート
- ClamAVにはコマンド版とデーモン版がある。デーモン版はTCPでファイル単位のウィルス検閲に対応している
- clamavのdockerイメージを使ってTCPでウィルススキャンする

## clamdホスト構築

clamavの公式イメージによると、

> The development of this image will be discontinued. Since 0.104 Cisco provides official docker images for clamav. This image here will be on hold and supported as long as possible.
> > このイメージの開発は終了します。0.104 以降、Cisco は clamav 用の公式 docker イメージを提供しています。こちらのイメージは保留され、可能な限りサポートされます。
[mkodockx/docker-clamav](https://mko-x.github.io/docker-clamav/)

一応ちゃんと動いてくれるが、現在開発停止していてデータベースの更新中に予期しない切断が発生するため、Ciscoの公式版を使ってくれとのこと。
[clamav/clamav | dockerhub](https://hub.docker.com/r/clamav/clamav)

```yml:Dockerfile
version: '3.2'
services:
  app: ...
    depends_on:
      - clamavd

  clamavd:
    container_name: clamavd
    hostname: clamavd
    image: clamav/clamav
    ports:
      - 3310
```

## ウィルスデータベース
freshclam

> ClamAVのウイルステータベースは、/var/lib/clamavディレクトリにmain.cvdおよびdaily.cvdというファイルで保存されている。
> これらのデータベースは、freshclamコマンドで更新する。
[ClamAVのウイルスデータベースを自動更新するには － ＠IT](https://atmarkit.itmedia.co.jp/flinux/rensai/linuxtips/930clamavdbup.html)

[clamav/clamav | dockerhub](https://hub.docker.com/r/clamav/clamav)イメージではfreshclamデーモンが一日一回(`--checks=1`)ウィルスデータベースを更新するようにチューニングされている。
freshclamコマンド版を使ってcrontabでスケジュールすることもできるが、まあおまかせのデーモン版で。

```sh:sh
ps | grep clamav
PID   USER     TIME  COMMAND
   12 clamav    0:00 freshclam --checks=1 --daemon --foreground --stdout --user=clamav
   13 clamav    0:22 clamd --foreground
```

なお、かつて高頻度でダウンロードをかけるクライアントに対策するため`freshclam`によるダウンロードに制限した経緯があるようだ。

> Abuse of the download system has forced us to push people towards FreshClam. Unfortunately a handful have ruined it for everyone. (Looking at you, handful of IPs that download the daily.cvd 3x a second)
> > ダウンロードシステムの乱用により、私たちは人々をFreshClamに追い込むことを余儀なくされました。 残念ながら、一握りがみんなのためにそれを台無しにしました。(あなたを見て、daily.cvdを1秒間に3回ダウンロードする一握りのIP)
[Official docker images of clamav](https://mko-x.github.io/docker-clamav/)

freshclamの設定ファイルを見るとデフォルトは12回(2時間に一回)のようだ。

```/etc/clamav/freshclam.conf
# Number of database checks per day.
# Default: 12 (every two hours)                             
#Checks 24   
```
[ClamAVによるリアルタイムスキャンの設定（ClamAV 1.0版） | 稲葉サーバーデザイン](https://inaba-serverdesign.jp/blog/20231122/clamav-realtime-scan-v10.html)

## clamavdのクライアント実装

`PING`コマンドで疎通を確認した後に、`INSTREAM`スキャンコマンドを投げる

```php
<?php

class ClamAVClient
{
    // clamdホストのデフォルト
    const CLAMAVD_HOST = 'clamavd';
    const CLAMAVD_PORT = 3310;

    private $_clamavd_host;
    private $_clamavd_port;
    private $_raw_fact;
    private $_socket;

    public function __construct($host=self::CLAMAVD_HOST, $port=self::CLAMAVD_PORT)
    {
        $this->_clamavd_host = $host;
        $this->_clamavd_port = $port;
    }

    public function __destruct()
    {
        $this->closeSocket();
    }

    /**
     * tcpソケットを閉じます
     */
    public function closeSocket(){
        if( is_resource($this->_socket) ) fclose($this->_socket);
    }

    /**
     * tcpソケットを開きます
     * @return resource TCPセッションハンドル
     */
    private function openSocket(){
        $this->_socket = stream_socket_client("tcp://{$this->_clamavd_host}:{$this->_clamavd_port}", $errno, $errstr, 10);
        if (!$this->_socket) {
            throw new RuntimeException("ホストの接続に失敗しました。({$errno}){$errstr}");
        }
        return $this->_socket;
    }

    /**
     * ソケットを取得します
     * @return resource TCPセッションハンドル
     */
    private function getSocket(){
        return $this->_socket ?: $this->openSocket();
    }

    /**
     * clamavdホストにPINGをリクエストします
     * @note PINGに対するclamavdの正常応答はPONG
     * @return bool true: 正常応答 / false: 異常応答
     */
    public function ping(){

        $socket = $this->getSocket();

        stream_socket_sendto($socket, 'PING');
        $this->_raw_fact = stream_socket_recvfrom($socket, 1024);

        return $this->fact() === 'PONG';
    }

    /**
     * clamavdのレスポンステキストからファクトを抽出します
     * @param string $response_text
     * @return string ファクト
     */
    public function fact(){
        $fact = (strrchr($this->_raw_fact, ":")) ?: ": {$this->_raw_fact}";
        return trim(substr($fact, 1));
    }

    /**
     * clamavdにファイルスキャンをリクエストします
     * 
     * @return bool true: ウィルスなし / false: ウイルスあり
     */
    public function scan($filepath){

        // ファイルが存在するか確認
        if ( file_exists($filepath) === false ) {
            throw new RuntimeException("ファイルが存在しません。file({$filepath})");
        }

        // ファイル内容を読み込み
        $fileContent = file_get_contents($filepath);
        if ($fileContent === false) {
            throw new RuntimeException("ファイルの読み込みに失敗しました。file({$filepath})");
        }

        // PINGセッション
        if( ( new ClamAVClient() )->ping() === false ) {
            throw new RuntimeException("PINGに失敗しました。");
        }

        // スキャンセッション
        $socket = $this->getSocket();

        // ストリームスキャンのハンドシェイク開始 => zINSTREAM\0
        $res = stream_socket_sendto($socket, "zINSTREAM\0");

        // ファイルを8KBでチャンク送信する
        $chunkSize = 8192;
        $bytesSent = 0;
        while ( $bytesSent < strlen($fileContent) ) {
            $chunk = substr($fileContent, $bytesSent, $chunkSize);
            $chunkLength = pack('N', strlen($chunk));
            $res = stream_socket_sendto($socket, $chunkLength . $chunk);
            $bytesSent += $chunkSize;
        }

        // スキャンのハンドシェイク終了 => ゼロバイトを送信
        $res = stream_socket_sendto($socket, pack('N', 0));

        // スキャン結果を読み取り => 問題なければ"OK"文字列が入っている
		// ウィルス検出	=> "stream: Eicar-Signature FOUND"
		// ウィルス未検出	=> "stream: OK"
        $this->_raw_fact = stream_socket_recvfrom($socket, 1024);        
        if( empty( $this->_raw_fact ) ){
            throw new RuntimeException("診断結果が空です。");
		}

        echo "clamscan result ({$filepath}) : {$this->_raw_fact}";
        return $this->fact() === 'OK';
    }
}
```

## 検閲

テストにはEICAR テストファイルを使用する。
[EICAR テストファイルとは？](https://support.kaspersky.co.jp/common/diagnostics/7399)

```sh:sh
echo "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" > /path/to/virus.txt
```

```php:php
try{
	$clamav = new ClamAVClient();
	$result = $clamav->scan('/path/to/virus.txt');
}catch(RuntimeException $e){
	echo "clamavスキャンで例外が発生 {$e->getMessage()}";	
}

if( $result === false ){
  	throw new RuntimeException("clamavスキャンで脅威を検出しました。fact({$clamav->fact()})"); //=> Eicar-Signature FOUND
}
```