ClamAVによるアップロードファイルのウィルスチェック

とあるRailsアプリケーションでアップロードされたファイルをウィルスチェックしたいというのがあって、実装したのでメモ。

方針

ClamAVというオープンソースのアンチウィルスエンジンがあるのでこれを使う。各種言語にクライアントライブラリ(Rubyclamav-clientというgem)があったりして便利。

このアプリケーションはDockerで動かしているので、ClamAVのコンテナを立ててデーモンを動かし、ファイルアップロード時にアップロードされたファイルをClamAVのデーモンに渡してウィルス判定されたらバリデーションエラーになるようにする。

また、ファイルアップロードには shrine という gem を使っている。

実装

環境

docker-compose.yml:

---
version: '3.5'
services:
  clamav:
    image: mkodockx/docker-clamav
    ports:
      - 3310
    volumes:
      - clamav:/var/lib/clamav
      - ./docker/clamav/clamd.conf:/etc/clamd/clamd.conf:ro
      - ./docker/clamav/freshclam.conf:/etc/clamd/freshclam.conf:ro

  app:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
      target: base
    command: >
      bash -c "
        rm -f tmp/pids/server.pid &&
        bundle exec rails s -p 3000 -b '0.0.0.0'"
    depends_on:
      - clamav
    volumes:
      - .:/app
    ports:
      - ${DOCKER_HOST_APP_PORT:-3000}:3000
    environment:
      CLAMD_TCP_HOST: clamav
      CLAMD_TCP_PORT: 3310

volumes:
  clamav:
    driver: local

clamd.conf:

# Daemon
Foreground true
LocalSocket /var/run/clamav/clamd.ctl
FixStaleSocket true
LocalSocketGroup clamav
LocalSocketMode 666
TCPSocket 3310

# Basic
User clamav
ScanMail true
ScanArchive true
ArchiveBlockEncrypted false
MaxDirectoryRecursion 15
FollowDirectorySymlinks false
FollowFileSymlinks false
ReadTimeout 180
MaxThreads 12
MaxConnectionQueueLength 15

# Log
LogFile /dev/stdout
LogSyslog false
LogRotate false
LogFacility LOG_LOCAL6
LogClean false
LogVerbose false
LogTime true
LogFileUnlock false
LogFileMaxSize 0

# Detail
PreludeEnable no
PreludeAnalyzerName ClamAV
DatabaseDirectory /var/lib/clamav
OfficialDatabaseOnly false
SelfCheck 3600
Debug false
ScanPE true
MaxEmbeddedPE 10M
ScanOLE2 true
ScanPDF true
ScanHTML true
MaxHTMLNormalize 10M
MaxHTMLNoTags 2M
MaxScriptNormalize 5M
MaxZipTypeRcg 1M
ScanSWF true
DetectBrokenExecutables false
ExitOnOOM false
LeaveTemporaryFiles false
AlgorithmicDetection true
ScanELF true
IdleTimeout 30
CrossFilesystems true
PhishingSignatures true
PhishingScanURLs true
PhishingAlwaysBlockSSLMismatch false
PhishingAlwaysBlockCloak false
PartitionIntersection false
DetectPUA false
ScanPartialMessages false
HeuristicScanPrecedence false
StructuredDataDetection false
CommandReadTimeout 5
SendBufTimeout 200
MaxQueue 100
ExtendedDetectionInfo true
OLE2BlockMacros false
ScanOnAccess false
AllowAllMatchScan true
ForceToDisk false
DisableCertCheck false
DisableCache false
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000
MaxPartitions 50
MaxIconsPE 100
PCREMatchLimit 10000
PCRERecMatchLimit 5000
PCREMaxFileSize 25M
ScanXMLDOCS true
ScanHWP3 true
MaxRecHWP3 16
StreamMaxLength 25M
Bytecode true
BytecodeSecurity TrustSigned
BytecodeTimeout 60000

freshclam.conf:

# Daemon
Foreground true

# Basic
DatabaseOwner clamav
Debug false
MaxAttempts 5
DatabaseDirectory /var/lib/clamav
DNSDatabaseInfo current.cvd.clamav.net
ConnectTimeout 30
ReceiveTimeout 30
TestDatabases yes
ScriptedUpdates yes
CompressLocalDatabase no
SafeBrowsing false
Bytecode true
NotifyClamd /etc/clamav/clamd.conf

# Log
UpdateLogFile /dev/stdout
LogVerbose false
LogSyslog false
LogFacility LOG_LOCAL6
LogFileMaxSize 0
LogRotate false
LogTime true

# Check
Checks 24
DatabaseMirror db.local.clamav.net
DatabaseMirror database.clamav.net

Rails

Gemfile:

...

# Upload file
gem 'shrine'

# Antivirus
gem 'clamav-client', require: 'clamav/client'

config/initializers/clamav.rb:

class << Rails.application
  def clamav
    Thread.current['clamav'] ||= ClamAV::Client.new(
      ClamAV::Connection.new(
        socket: TCPSocket.new(Settings.clamav.tcp_host, Settings.clamav.tcp_port),
        wrapper: ClamAV::Wrappers::NewLineWrapper.new
      )
    )
  end
end

app/uploaders/basic_uploader.rb:

class BasicUploader < Shrine
  plugin :validation_helpers

  Attacher.validate do
    result = Rails.application.clamav.execute(ClamAV::Commands::InstreamCommand.new(get))
    errors << :has_virus if result.virus_name.present?
  end
end

app/models/hoge_model.rb:

class HogeModel < ApplicationRecord
  include BasicUploader::Attachment.new(:file)
end

テスト

EICARがウィルスチェック用の無害なテストウィルスを配布しているのでこれを使う。

$ docker-compose exec app rails c
irb(main):001:0> model = HogeModel.new(file: Rails.root.join('fixtures/antivirus/eicar.com').open)
irb(main):002:0> model.valid?
irb(main):003:0> model.errors.details
{:file=>[{:error=>:has_virus}]}
irb(main):004:0> model = HogeModel.new(file: Rails.root.join('REDME.md').open)
irb(main):005:0> model.valid?
irb(main):006:0> model.errors.details
{}