├── .github └── workflows │ └── ruby-ci.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── clamby.gemspec ├── clamby_logo.png ├── lib ├── clamby.rb └── clamby │ ├── command.rb │ ├── error.rb │ └── version.rb └── spec ├── .DS_Store ├── clamby └── command_spec.rb ├── clamby_spec.rb ├── fixtures ├── safe (special).txt └── safe.txt ├── spec_helper.rb └── support └── shared_context.rb /.github/workflows/ruby-ci.yml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby-version: ['2.6.10', '2.7.8', '3.0.6', '3.1.4', '3.2.2', '3.3.0'] 11 | gemfile: ['Gemfile'] 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby-version }} 21 | bundler: 2.4.17 22 | 23 | - name: Install dependencies 24 | run: | 25 | sudo apt-get update 26 | sudo groupadd clamav 27 | sudo useradd -g clamav -s /bin/false -c "Clam Antivirus" clamav 28 | sudo apt-get install -y clamav 29 | sudo systemctl stop clamav-freshclam 30 | sudo pkill freshclam || true 31 | sudo freshclam 32 | 33 | - name: Install Gems 34 | run: | 35 | gem install rake 36 | gem install rspec 37 | bundle install --jobs 4 --retry 3 38 | 39 | - name: Run tests 40 | run: bundle exec rspec 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .DS_Store/ 19 | .byebug_history 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.6.11 2 | - Updated the tests to pass in Ruby 3.x. This was an issue with rspec-mocks not being able to access the frozen $CHILD_STATUS object. The $CHILD_STATUS was extracted to a class method so that the method could be mocked in the tests with the expected results. 3 | 4 | # v1.6.10 5 | - Moved from Travis CI to GitHub Actions 6 | 7 | # v1.6.9 8 | - [AndreasRonnqvistCytiva](https://github.com/kobaltz/clamby/commits?author=AndreasRonnqvistCytiva) - Allow reload option #44 9 | 10 | # v1.6.8 11 | - [codezomb](https://github.com/kobaltz/clamby/commits?author=codezomb) - Allow paths to be escaped #37 12 | 13 | # v1.6.5 14 | - [bennacer860](https://github.com/kobaltz/clamby/commits?author=bennacer860) - Added config data dir option 15 | 16 | 17 | # v1.6.2 18 | - [emilong](https://github.com/kobaltz/clamby/commits?author=emilong) - Handle nil exit status of clamav executable. 19 | 20 | # v1.6.1 21 | - [broder](https://github.com/kobaltz/clamby/commits?author=broder) - Fixed issue with detecting clamdscan version when using custom config file 22 | 23 | # v1.6.0 24 | - When checking version, use the executable configuration. 25 | 26 | # v1.5.1 27 | - [ahukkanen](https://github.com/kobaltz/clamby/commits?author=ahukkanen) - Configurable execution paths 28 | 29 | # v1.5.0 30 | - Exceptions are now raised under Clamby module - could be breaking change 31 | - [szajbus](https://github.com/kobaltz/clamby/commits?author=szajbus) fixed specs! and updated README 32 | - [broder](https://github.com/kobaltz/clamby/commits?author=broder) added path for config file to address strange clamscan situations 33 | - [tilsammans](https://github.com/kobaltz/clamby/commits?author=tilsammans) added queit, verbose and Command class 34 | 35 | # v1.4.0 36 | - [emilong](https://github.com/kobaltz/clamby/commits/master?author=emilong) added `:error_clamscan_client_error => false` option to prevent error from missing running daemon or clamscan. 37 | 38 | # v1.3.2 39 | - [emilong](https://github.com/kobaltz/clamby/commits/master?author=emilong) added `stream` option 40 | 41 | # v1.3.1 42 | - [zealot128](https://github.com/kobaltz/clamby/commits/master?author=zealot128) added `silence_output` option 43 | 44 | # v1.3.0 45 | - Fixed Dangerous Send on `system_command` method 46 | 47 | # v1.2.5 48 | - [bess](https://github.com/kobaltz/clamby/commits/master?author=bess) added `fdpass` option 49 | 50 | # v1.2.3 51 | - Fixed typo in config check `error_clamscan_missing` instead of `error_clamdscan_missing` 52 | 53 | # v1.1.1 54 | - Daemonize option added 55 | - Refactor of logic 56 | - Cleanup 57 | - Thanks to @hderms for contributing! 58 | 59 | # v1.1.0 60 | - Changed `scan()` to `safe?()` 61 | - Added `virus?()` 62 | - Added/Changed `rspec` to accomodate new/changed functionality 63 | 64 | # v1.0.5 65 | - Made default virus detection not throw a warning 66 | - If scanning a file that doesn't exist, `scan(path)` will return nil. 67 | - If scanning a file where `clamscan` doesn't exist, `scan(path)` will return nil. 68 | - Added test for nil result on scanning a file that doesn't exist 69 | 70 | # v1.0.4 71 | - Added tests. This WILL download a file with a virus signature. It is a safe file, but is used for the purposes of testing the detection of a virus. Regardless, use caution when running rspec as this could be potentially harmful (doubtful, but be warned). 72 | 73 | ```ruby 74 | .ClamAV 0.98.1/18563/Sun Mar 9 17:39:31 2014 75 | .FILE NOT FOUND on 2014-03-10 21:35:44 -0400: BAD_FILE.md 76 | .ClamAV 0.98.1/18563/Sun Mar 9 17:39:31 2014 77 | README.md: OK 78 | .--2014-03-10 21:35:50-- http://www.eicar.org/download/eicar.com 79 | Resolving www.eicar.org... 188.40.238.250 80 | Connecting to www.eicar.org|188.40.238.250|:80... connected. 81 | HTTP request sent, awaiting response... 200 OK 82 | Length: 68 [application/octet-stream] 83 | Saving to: 'eicar.com' 84 | 85 | 100%[=================>] 68 --.-K/s in 0s 86 | 87 | 2014-03-10 21:35:50 (13.0 MB/s) - 'eicar.com' saved [68/68] 88 | 89 | ClamAV 0.98.1/18563/Sun Mar 9 17:39:31 2014 90 | eicar.com: Eicar-Test-Signature FOUND 91 | ClamAV 0.98.1/18563/Sun Mar 9 17:39:31 2014 92 | eicar.com: Eicar-Test-Signature FOUND 93 | VIRUS DETECTED on 2014-03-10 21:36:02 -0400: eicar.com 94 | . 95 | 96 | Finished in 17.79 seconds 97 | 5 examples, 0 failures 98 | ```` 99 | 100 | - Changed `scanner_exists?` method to check `clamscan -V` for version instead of just `clamscan` which was causing a virus scan on the local folder. This ended up throwing false checks since I had a virus test file in the root of the directory. 101 | 102 | # v1.0.3 103 | - Added exceptions 104 | - New configuration options 105 | 106 | ```ruby 107 | Clamby.configure({ 108 | :check => false, 109 | :error_clamscan_missing => false, 110 | :error_file_missing => false, 111 | :error_file_virus => false 112 | }) 113 | 114 | ``` 115 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in clamby.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 kobaltz 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Clamby Logo](https://raw.github.com/kobaltz/clamby/master/clamby_logo.png) 2 | 3 | [![GemVersion](https://badge.fury.io/rb/clamby.png)](https://badge.fury.io/rb/clamby.png) 4 | [![Ruby CI](https://github.com/kobaltz/clamby/actions/workflows/ruby-ci.yml/badge.svg)](https://github.com/kobaltz/clamby/actions/workflows/ruby-ci.yml) 5 | 6 | This gem depends on the [clamscan](http://www.clamav.net/) and `freshclam` daemons to be installed already. 7 | 8 | If you have a file upload on your site and you do not scan the files for viruses then you not only compromise your software, but also the users of the software and their files. This gem's function is to simply scan a given file. 9 | 10 | # Usage 11 | 12 | [Drifting Ruby Screencast](https://www.driftingruby.com/episodes/antivirus-uploads-with-clamby?utm_source=github&utm_medium=social&utm_campaign=github) 13 | 14 | Be sure to check the CHANGELOG as recent changes may affect functionality. 15 | 16 | Just add `gem 'clamby'` to your `Gemfile` and run `bundle install`. 17 | 18 | You can use two methods to scan a file for a virus: 19 | 20 | If you use `safe?` to scan a file, it will return `true` if no viruses were found, `false` if a virus was found, and `nil` if there was a problem finding the file or if there was a problem using `clamscan` 21 | 22 | `Clamby.safe?(path_to_file)` 23 | 24 | If you use `virus?` to scan a file, it will return `true` if a virus was found, `false` if no virus was found, and `nil` if there was a problem finding the file or if there was a problem using `clamscan` 25 | 26 | 27 | `Clamby.virus?(path_to_file)` 28 | 29 | In your model with the uploader, you can add the scanner to a before method to scan the file. When a file is scanned, a successful scan will return `true`. An unsuccessful scan will return `false`. A scan may be unsuccessful for a number of reasons; `clamscan` could not be found, `clamscan` returned a virus, or the file which you were trying to scan could not be found. 30 | 31 | ```ruby 32 | before_create :scan_for_viruses 33 | 34 | private 35 | 36 | def scan_for_viruses 37 | path = self.attribute.url 38 | Clamby.safe?(path) 39 | end 40 | ``` 41 | 42 | ***Updating Definitions*** 43 | 44 | I have done little testing with updating definitions online. However, there is a method that you can call `Clamby.update` which will execute `freshclam`. It is recommended that you follow the instructions below to ensure that this is done automatically on a daily/weekly basis. 45 | 46 | ***Viruses Detected*** 47 | 48 | It's good to note that Clamby will not by default delete files which had a virus. Instead, this is left to you to decide what should occur with that file. Below is an example where if a scan came back `false`, the file would be deleted. 49 | 50 | ```ruby 51 | before_create :scan_for_viruses 52 | 53 | private 54 | 55 | def scan_for_viruses 56 | path = self.attribute.path 57 | scan_result = Clamby.safe?(path) 58 | if scan_result 59 | return true 60 | else 61 | File.delete(path) 62 | return false 63 | end 64 | end 65 | ``` 66 | 67 | ## with ActiveStorage 68 | 69 | With ActiveStorage, you don't have access to the file through normal methods, so you'll have to access the file through the `attachment_changes`. 70 | 71 | ```ruby 72 | class User < ApplicationRecord 73 | has_one_attached :avatar 74 | before_save :scan_for_viruses 75 | 76 | private 77 | 78 | def scan_for_viruses 79 | return unless self.attachment_changes['avatar'] 80 | 81 | path = self.attachment_changes['avatar'].attachable.tempfile.path 82 | Clamby.safe?(path) 83 | end 84 | end 85 | ``` 86 | 87 | # Configuration 88 | 89 | Configuration is rather limited right now. You can exclude the check if `clamscan` exists which will save a bunch of time for scanning your files. However, for development purposes, your machine may not have `clamscan` installed and you may wonder why it's not working properly. This is just to give you a reminder to install `clamscan` on your development machine and production machine. You can add the following to an initializer, for example `config/initializers/clamby.rb`: 90 | 91 | ```ruby 92 | Clamby.configure({ 93 | :check => false, 94 | :daemonize => false, 95 | :config_file => nil, 96 | :error_clamscan_missing => true, 97 | :error_clamscan_client_error => false, 98 | :error_file_missing => true, 99 | :error_file_virus => false, 100 | :fdpass => false, 101 | :stream => false, 102 | :reload => false, 103 | :output_level => 'medium', # one of 'off', 'low', 'medium', 'high' 104 | :executable_path_clamscan => 'clamscan', 105 | :executable_path_clamdscan => 'clamdscan', 106 | :executable_path_freshclam => 'freshclam', 107 | }) 108 | ``` 109 | 110 | #### Daemonize 111 | 112 | I highly recommend using the `daemonize` set to `true`. This will allow for clamscan to remain in memory and will not have to load for each virus scan. It will save several seconds per request. 113 | 114 | To specify a config file for clamdscan to use, you can set `config_file` with the relevant path. See [this page](https://linux.die.net/man/5/clamd.conf) for more information about the config file. 115 | 116 | #### Error suppression 117 | 118 | There has been added additional functionality where you can override exceptions. If you set the exceptions below to false, then there will not be a hard exception generated. Instead, it will post to your log that an error had occured. By default each one of these configuration options are set to true. You may want to set these to false in your production environment. 119 | 120 | #### Pass file descriptor permissions to clamd 121 | 122 | Setting the `fdpass` configuration option to `true` will pass the `--fdpass` option to clamscan. This might be useful if you are encountering permissions problems between clamscan and files being created by your application. From the clamscan man page: 123 | 124 | `--fdpass : Pass the file descriptor permissions to clamd. This is useful if clamd is running as a different user as it is faster than streaming the file to clamd. Only available if connected to clamd via local(unix) socket.` 125 | 126 | #### Force streaming files to clamd 127 | 128 | Setting the `stream` configuration option will stream the file to the daemon. This may be useful for forcing streaming as a test for local development. Only works when also specifying `daemonize`. From the clamdscan man page: 129 | 130 | `--stream : Forces file streaming to clamd. This is generally not needed as clamdscan detects automatically if streaming is required. This option only exists for debugging and testing purposes, in all other cases --fdpass is preferred.` 131 | 132 | #### Force streaming files to clamd 133 | 134 | Setting the `reload` configuration option to `true` will pass the `--reload` option to the daemon. Only works when also specifying `daemonize`. From the clamdscan man page: 135 | 136 | `--reload : Request clamd to reload virus database.` 137 | 138 | #### Output levels 139 | 140 | - *off*: suppress all output 141 | - *low*: show errors, but nothing else 142 | - *medium*: show errors and briefly what happened _(default)_ 143 | - *high*: as verbose as possible 144 | 145 | #### Executable paths 146 | 147 | Setting any of the executable paths makes Clamby to use those paths instead of 148 | the defaults for the system calls. The default configuration for these should be 149 | perfectly fine for most use cases but it may be required to configure custom 150 | paths in case ClamAV is manually installed to a custom location. 151 | 152 | In order to configure the paths, use the following configuration options: 153 | 154 | - *executable_path_clamscan*: defines the executable path for the `clamscan` 155 | executable, defaults to `clamscan` 156 | - *executable_path_clamdscan*: defines the executable path for the `clamdscan` 157 | executable, defaults to `clamdscan` 158 | - *executable_path_freshclam*: defines the executable path for the `freshclam` 159 | executable, defaults to `freshclam` 160 | 161 | # Dependencies 162 | 163 | ***Ubuntu*** 164 | 165 | `sudo apt-get install clamav clamav-daemon` 166 | 167 | Note, `clamav-daemon` is optional but recommended. It's needed if you wish to 168 | run ClamAV in daemon mode. 169 | 170 | ***macOS*** 171 | 172 | `brew install clamav` 173 | 174 | ***Auto Update**** 175 | 176 | To update the virus database, open a terminal and enter the following command: 177 | 178 | `sudo freshclam` 179 | 180 | To automate this update you can set up a cron job. I'll show how to update the virus database every day at 8:57 PM. 181 | 182 | You need to modify the crontab for the root user. 183 | 184 | `sudo crontab -e` 185 | 186 | This opens the root crontab file in a text editor. Add the following line 187 | 188 | `57 08 * * * sudo freshclam` 189 | 190 | # Contributors 191 | 192 | 193 | 194 | 195 | # LICENSE 196 | 197 | Copyright (c) 2016 kobaltz 198 | 199 | MIT License 200 | 201 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 202 | 203 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 204 | 205 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 206 | 207 | ClamAV is licensed under [GNU GPL](http://www.gnu.org/licenses/gpl.html). The ClamAV software is NOT distributed nor modified with this gem. 208 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /clamby.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'clamby/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "clamby" 8 | spec.version = Clamby::VERSION 9 | spec.authors = ["kobaltz"] 10 | spec.email = ["dave@k-innovations.net"] 11 | spec.summary = "Scan file uploads with ClamAV" 12 | spec.description = "Clamby allows users to scan files uploaded with Paperclip or Carrierwave. If a file has a virus, then you can delete this file and discard it without causing harm to other users." 13 | spec.homepage = "https://github.com/kobaltz/clamby" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "rspec" 24 | end 25 | -------------------------------------------------------------------------------- /clamby_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kobaltz/clamby/fdd9d92b123006765e944a4d3b99dc305bc29ce9/clamby_logo.png -------------------------------------------------------------------------------- /lib/clamby.rb: -------------------------------------------------------------------------------- 1 | require "English" 2 | require "clamby/command" 3 | require "clamby/error" 4 | require "clamby/version" 5 | 6 | module Clamby 7 | DEFAULT_CONFIG = { 8 | :check => true, 9 | :daemonize => false, 10 | :config_file => nil, 11 | :error_clamscan_missing => true, 12 | :error_clamscan_client_error => false, 13 | :error_file_missing => true, 14 | :error_file_virus => false, 15 | :fdpass => false, 16 | :stream => false, 17 | :reload => false, 18 | :output_level => 'medium', 19 | :datadir => nil, 20 | :executable_path_clamscan => 'clamscan', 21 | :executable_path_clamdscan => 'clamdscan', 22 | :executable_path_freshclam => 'freshclam', 23 | }.freeze 24 | 25 | @config = DEFAULT_CONFIG.dup 26 | 27 | @valid_config_keys = @config.keys 28 | 29 | class << self 30 | attr_reader :config 31 | attr_reader :valid_config_keys 32 | end 33 | 34 | def self.configure(opts = {}) 35 | if opts.delete(:silence_output) 36 | warn ':silence_output config is deprecated. Use :output_level => "off" instead.' 37 | opts[:output_level] = 'off' 38 | end 39 | 40 | opts.each {|k,v| config[k.to_sym] = v if valid_config_keys.include? k.to_sym} 41 | end 42 | 43 | def self.safe?(path) 44 | value = virus?(path) 45 | return nil if value.nil? 46 | ! value 47 | end 48 | 49 | def self.virus?(path) 50 | return nil unless scanner_exists? 51 | Command.scan path 52 | end 53 | 54 | def self.scanner_exists? 55 | return true unless config[:check] 56 | scanner = Command.clamscan_version 57 | 58 | return true if scanner 59 | return false unless config[:error_clamscan_missing] 60 | 61 | raise Clamby::ClamscanMissing.new("#{Command.scan_executable} not found. Check your installation and path.") 62 | end 63 | 64 | def self.update 65 | Command.freshclam 66 | end 67 | 68 | def self.daemonize? 69 | !! config[:daemonize] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/clamby/command.rb: -------------------------------------------------------------------------------- 1 | module Clamby 2 | # Interface with the system. Builds and runs the command. 3 | class Command 4 | EXECUTABLES = %w(clamscan clamdscan freshclam) 5 | 6 | # Array containing the complete command line. 7 | attr_accessor :command 8 | 9 | # Returns the appropriate scan executable, based on clamd being used. 10 | def self.scan_executable 11 | return 'clamdscan' if Clamby.config[:daemonize] 12 | return 'clamscan' 13 | end 14 | 15 | # $CHILD_STATUS maybe nil if the execution itself (not the client process) 16 | def self.scan_status 17 | $CHILD_STATUS && $CHILD_STATUS.exitstatus 18 | end 19 | 20 | # Perform a ClamAV scan on the given path. 21 | def self.scan(path) 22 | return nil unless file_exists?(path) 23 | 24 | args = [Shellwords.escape(path), '--no-summary'] 25 | 26 | if Clamby.config[:daemonize] 27 | args << '--fdpass' if Clamby.config[:fdpass] 28 | args << '--stream' if Clamby.config[:stream] 29 | args << '--reload' if Clamby.config[:reload] 30 | end 31 | 32 | args << "-d #{Clamby.config[:datadir]}" if Clamby.config[:datadir] 33 | 34 | new.run scan_executable, *args 35 | 36 | case self.scan_status 37 | when 0 38 | return false 39 | when nil, 2 40 | # clamdscan returns 2 whenever error other than a detection happens 41 | if Clamby.config[:error_clamscan_client_error] && Clamby.config[:daemonize] 42 | raise Clamby::ClamscanClientError.new("Clamscan client error") 43 | end 44 | 45 | # returns true to maintain legacy behavior 46 | return true 47 | else 48 | return true unless Clamby.config[:error_file_virus] 49 | 50 | raise Clamby::VirusDetected.new("VIRUS DETECTED on #{Time.now}: #{path}") 51 | end 52 | end 53 | 54 | # Update the virus definitions. 55 | def self.freshclam 56 | args = [] 57 | args << "--datadir=#{Clamby.config[:datadir]}" if Clamby.config[:datadir] 58 | new.run 'freshclam', *args 59 | end 60 | 61 | # Show the ClamAV version. Also acts as a quick check if ClamAV functions. 62 | def self.clamscan_version 63 | new.run scan_executable, '--version' 64 | end 65 | 66 | # Run the given commands via a system call. 67 | # The executable must be one of the permitted ClamAV executables. 68 | # The arguments will be combined with default arguments if needed. 69 | # The arguments are sorted alphabetically before being passed to the system. 70 | # 71 | # Examples: 72 | # run('clamscan', file, '--verbose') 73 | # run('clamscan', '-V') 74 | def run(executable, *args) 75 | executable_full = executable_path(executable) 76 | self.command = args | default_args 77 | self.command = command.sort.unshift(executable_full) 78 | 79 | system(self.command.join(' '), system_options) 80 | end 81 | 82 | private 83 | 84 | def default_args 85 | args = [] 86 | args << "--config-file=#{Clamby.config[:config_file]}" if Clamby.config[:daemonize] && Clamby.config[:config_file] 87 | args << '--quiet' if Clamby.config[:output_level] == 'low' 88 | args << '--verbose' if Clamby.config[:output_level] == 'high' 89 | args 90 | end 91 | 92 | # This applies to the `system` call itself; does not end up in the command. 93 | def system_options 94 | if Clamby.config[:output_level] == 'off' 95 | { out: File::NULL } 96 | else 97 | {} 98 | end 99 | end 100 | 101 | def executable_path(executable) 102 | raise "`#{executable}` is not permitted" unless EXECUTABLES.include?(executable) 103 | Clamby.config[:"executable_path_#{executable}"] 104 | end 105 | 106 | def self.file_exists?(path) 107 | return true if File.file?(path) 108 | 109 | if Clamby.config[:error_file_missing] 110 | raise Clamby::FileNotFound.new("File not found: #{path}") 111 | else 112 | puts "FILE NOT FOUND on #{Time.now}: #{path}" 113 | return false 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/clamby/error.rb: -------------------------------------------------------------------------------- 1 | module Clamby 2 | class Error < StandardError; end 3 | 4 | class VirusDetected < Error; end 5 | class ClamscanMissing < Error; end 6 | class ClamscanClientError < Error; end 7 | class FileNotFound < Error; end 8 | end 9 | -------------------------------------------------------------------------------- /lib/clamby/version.rb: -------------------------------------------------------------------------------- 1 | module Clamby 2 | VERSION = "1.6.11" 3 | end 4 | -------------------------------------------------------------------------------- /spec/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kobaltz/clamby/fdd9d92b123006765e944a4d3b99dc305bc29ce9/spec/.DS_Store -------------------------------------------------------------------------------- /spec/clamby/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'support/shared_context' 3 | 4 | describe Clamby::Command do 5 | before { Clamby.configure(Clamby::DEFAULT_CONFIG.dup) } 6 | 7 | describe 'ClamAV version' do 8 | it 'returns true' do 9 | command = described_class.clamscan_version 10 | expect(command).to be true 11 | end 12 | end 13 | 14 | describe 'scan' do 15 | include_context 'paths' 16 | 17 | let(:runner){ instance_double(described_class) } 18 | 19 | describe 'exceptions' do 20 | it "can be configured to raise exception when file is missing" do 21 | Clamby.configure({:error_file_missing => true}) 22 | 23 | expect do 24 | described_class.scan(bad_path) 25 | end.to raise_exception(Clamby::FileNotFound) 26 | end 27 | it 'can be configured to return nil when file is missing' do 28 | Clamby.configure({:error_file_missing => false}) 29 | command = described_class.scan(bad_path) 30 | 31 | expect(command).to be(nil) 32 | end 33 | end 34 | 35 | describe 'passing file descriptor' do 36 | it 'does not include fdpass in the command by default' do 37 | Clamby.configure 38 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 39 | allow(described_class).to receive(:new).and_return(runner) 40 | 41 | described_class.scan(good_path) 42 | end 43 | 44 | it 'omits the fdpass option when invoking clamscan if it is set, but daemonize isn\'t' do 45 | Clamby.configure(fdpass: true) 46 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 47 | allow(described_class).to receive(:new).and_return(runner) 48 | 49 | described_class.scan(good_path) 50 | end 51 | 52 | it 'passes the fdpass option when invoking clamscan if it is set with daemonize' do 53 | Clamby.configure(fdpass: true, daemonize: true) 54 | expect(runner).to receive(:run).with('clamdscan', good_path, '--no-summary', '--fdpass') 55 | allow(described_class).to receive(:new).and_return(runner) 56 | 57 | described_class.scan(good_path) 58 | end 59 | end 60 | 61 | describe 'streaming files to clamd' do 62 | it 'does not include stream in the command by default' do 63 | Clamby.configure 64 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 65 | allow(described_class).to receive(:new).and_return(runner) 66 | 67 | described_class.scan(good_path) 68 | end 69 | 70 | it 'omits the stream option when invoking clamscan if it is set, but daemonize isn\'t' do 71 | Clamby.configure(stream: true) 72 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 73 | allow(described_class).to receive(:new).and_return(runner) 74 | 75 | described_class.scan(good_path) 76 | end 77 | 78 | it 'passes the stream option when invoking clamscan if it is set with daemonize' do 79 | Clamby.configure(stream: true, daemonize: true) 80 | expect(runner).to receive(:run).with('clamdscan', good_path, '--no-summary', '--stream') 81 | allow(described_class).to receive(:new).and_return(runner) 82 | 83 | described_class.scan(good_path) 84 | end 85 | end 86 | 87 | describe 'reloading virus database' do 88 | it 'does not include reload in the command by default' do 89 | Clamby.configure 90 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 91 | allow(described_class).to receive(:new).and_return(runner) 92 | 93 | described_class.scan(good_path) 94 | end 95 | 96 | it 'omits the reload option when invoking clamscan if it is set, but daemonize isn\'t' do 97 | Clamby.configure(reload: true) 98 | expect(runner).to receive(:run).with('clamscan', good_path, '--no-summary') 99 | allow(described_class).to receive(:new).and_return(runner) 100 | 101 | described_class.scan(good_path) 102 | end 103 | 104 | it 'passes the reload option when invoking clamscan if it is set with daemonize' do 105 | Clamby.configure(reload: true, daemonize: true) 106 | expect(runner).to receive(:run).with('clamdscan', good_path, '--no-summary', '--reload') 107 | allow(described_class).to receive(:new).and_return(runner) 108 | 109 | described_class.scan(good_path) 110 | end 111 | end 112 | 113 | describe 'specifying config-file' do 114 | it 'does not include the parameter in the clamscan command by default' do 115 | Clamby.configure 116 | 117 | expect(described_class.new.send(:default_args)).not_to include(a_string_matching(/--config-file/)) 118 | end 119 | it 'does not include the parameter in the clamdscan command by default' do 120 | Clamby.configure(daemonize: true) 121 | 122 | expect(described_class.new.send(:default_args)).not_to include(a_string_matching(/--config-file/)) 123 | end 124 | it 'omits the parameter when invoking clamscan if it is set' do 125 | Clamby.configure(daemonize: false, config_file: 'clamd.conf') 126 | 127 | expect(described_class.new.send(:default_args)).not_to include('--config-file=clamd.conf') 128 | end 129 | it 'passes the parameter when invoking clamdscan if it is set' do 130 | Clamby.configure(daemonize: true, config_file: 'clamd.conf') 131 | 132 | expect(described_class.new.send(:default_args)).to include('--config-file=clamd.conf') 133 | end 134 | end 135 | 136 | describe 'specifying custom executable paths' do 137 | let(:runner) { described_class.new } 138 | let(:custom_path) { '/custom/path' } 139 | 140 | before do 141 | Clamby.configure( 142 | executable_path_clamscan: "#{custom_path}/clamscan", 143 | executable_path_clamdscan: "#{custom_path}/clamdscan", 144 | executable_path_freshclam: "#{custom_path}/freshclam", 145 | ) 146 | allow(described_class).to receive(:new).and_return(runner) 147 | end 148 | 149 | it 'executes the freshclam executable from the custom path' do 150 | expect(runner).to receive(:system).with( 151 | "#{custom_path}/freshclam", 152 | {} 153 | ) { system("exit 0", out: File::NULL) } 154 | 155 | described_class.freshclam 156 | end 157 | 158 | context 'when not set with daemonize' do 159 | before { Clamby.configure(daemonize: false) } 160 | 161 | it 'executes the clamscan executable from the custom path' do 162 | expect(runner).to receive(:system).with( 163 | "#{custom_path}/clamscan --no-summary #{good_path}", 164 | {} 165 | ) { system("exit 0", out: File::NULL) } 166 | 167 | described_class.scan(good_path) 168 | end 169 | end 170 | 171 | context 'when set with daemonize' do 172 | before { Clamby.configure(daemonize: true) } 173 | 174 | it 'executes the clamdscan executable from the custom path' do 175 | expect(runner).to receive(:system).with( 176 | "#{custom_path}/clamdscan --no-summary #{good_path}", 177 | {} 178 | ) { system("exit 0", out: File::NULL) } 179 | 180 | described_class.scan(good_path) 181 | end 182 | end 183 | end 184 | 185 | describe 'special filenames' do 186 | it 'does not fail' do 187 | expect(described_class.scan(special_path)).to be(false) 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /spec/clamby_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'support/shared_context' 3 | 4 | describe Clamby do 5 | include_context 'paths' 6 | 7 | before { Clamby.configure(Clamby::DEFAULT_CONFIG.dup) } 8 | 9 | it "should find clamscan" do 10 | expect(Clamby.scanner_exists?).to be true 11 | end 12 | 13 | it "should scan file as safe" do 14 | expect(Clamby.safe?(good_path)).to be true 15 | expect(Clamby.virus?(good_path)).to be false 16 | end 17 | 18 | it "should scan file and return nil" do 19 | Clamby.configure({:error_file_missing => false}) 20 | expect(Clamby.safe?(bad_path)).to be nil 21 | expect(Clamby.virus?(bad_path)).to be nil 22 | end 23 | 24 | it "should scan file as dangerous" do 25 | begin 26 | file = download('https://secure.eicar.org/eicar.com') 27 | rescue SocketError => error 28 | pending("Skipped because reasons: #{error}") 29 | end 30 | 31 | dangerous = file.path 32 | Clamby.configure({:error_file_virus => true}) 33 | expect{Clamby.safe?(dangerous)}.to raise_exception(Clamby::VirusDetected) 34 | expect{Clamby.virus?(dangerous)}.to raise_exception(Clamby::VirusDetected) 35 | Clamby.configure({:error_file_virus => false}) 36 | expect(Clamby.safe?(dangerous)).to be false 37 | expect(Clamby.virus?(dangerous)).to be true 38 | File.delete(dangerous) 39 | end 40 | 41 | # From the clamscan man page: 42 | # Pass the file descriptor permissions to clamd. This is useful if clamd is 43 | # running as a different user as it is faster than streaming the file to 44 | # clamd. Only available if connected to clamd via local(unix) socket. 45 | context 'fdpass option' do 46 | it 'is false by default' do 47 | expect(Clamby.config[:fdpass]).to eq false 48 | end 49 | it 'accepts an fdpass option in the config' do 50 | Clamby.configure(fdpass: true) 51 | expect(Clamby.config[:fdpass]).to eq true 52 | end 53 | end 54 | 55 | # From the clamscan man page: 56 | # Forces file streaming to clamd. This is generally not needed as clamdscan 57 | # detects automatically if streaming is required. This option only exists for 58 | # debugging and testing purposes, in all other cases --fdpass is preferred. 59 | context 'stream option' do 60 | it 'is false by default' do 61 | expect(Clamby.config[:stream]).to eq false 62 | end 63 | it 'accepts an stream option in the config' do 64 | Clamby.configure(stream: true) 65 | expect(Clamby.config[:stream]).to eq true 66 | end 67 | end 68 | 69 | # From the clamscan man page: 70 | # Request clamd to reload virus database. 71 | context 'reload option' do 72 | it 'is false by default' do 73 | expect(Clamby.config[:reload]).to eq false 74 | end 75 | it 'accepts an reload option in the config' do 76 | Clamby.configure(reload: true) 77 | expect(Clamby.config[:reload]).to eq true 78 | end 79 | end 80 | 81 | context 'error_clamscan_client_error option' do 82 | it 'is false by default' do 83 | expect(Clamby.config[:error_clamscan_client_error]).to eq false 84 | end 85 | it 'accepts an error_clamscan_client_error option in the config' do 86 | Clamby.configure(error_clamscan_client_error: true) 87 | expect(Clamby.config[:error_clamscan_client_error]).to eq true 88 | end 89 | 90 | before { 91 | Clamby.configure(check: false) 92 | allow_any_instance_of(Process::Status).to receive(:exitstatus).and_return(2) 93 | allow(Clamby).to receive(:system) 94 | } 95 | 96 | context 'when false' do 97 | before do 98 | Clamby.configure(error_clamscan_client_error: false) 99 | allow(Clamby::Command).to receive(:scan_status).and_return(2) 100 | end 101 | 102 | it 'virus? returns true when the daemonized client exits with status 2' do 103 | Clamby.configure(daemonize: true) 104 | expect(Clamby.virus?(good_path)).to eq true 105 | end 106 | it 'returns true when the client exits with status 2' do 107 | Clamby.configure(daemonize: false) 108 | expect(Clamby.virus?(good_path)).to eq true 109 | end 110 | end 111 | 112 | context 'when true' do 113 | before do 114 | Clamby.configure(error_clamscan_client_error: true) 115 | allow(Clamby::Command).to receive(:scan_status).and_return(2) 116 | end 117 | 118 | it 'virus? raises when the daemonized client exits with status 2' do 119 | Clamby.configure(daemonize: true) 120 | expect { Clamby.virus?(good_path) }.to raise_error(Clamby::ClamscanClientError) 121 | end 122 | it 'returns true when the client exits with status 2' do 123 | Clamby.configure(daemonize: false) 124 | expect(Clamby.virus?(good_path)).to eq true 125 | end 126 | end 127 | end 128 | 129 | context 'executable paths' do 130 | context 'executable_path_clamscan option' do 131 | it 'is clamscan by default' do 132 | expect(Clamby.config[:executable_path_clamscan]).to eq 'clamscan' 133 | end 134 | it 'accepts an executable_path_clamscan option in the config' do 135 | path = '/custom/path/clamscan' 136 | Clamby.configure(executable_path_clamscan: path) 137 | expect(Clamby.config[:executable_path_clamscan]).to eq path 138 | end 139 | end 140 | 141 | context 'executable_path_clamdscan option' do 142 | it 'is clamdscan by default' do 143 | expect(Clamby.config[:executable_path_clamdscan]).to eq 'clamdscan' 144 | end 145 | it 'accepts an executable_path_clamdscan option in the config' do 146 | path = '/custom/path/clamdscan' 147 | Clamby.configure(executable_path_clamdscan: path) 148 | expect(Clamby.config[:executable_path_clamdscan]).to eq path 149 | end 150 | end 151 | 152 | context 'executable_path_freshclam option' do 153 | it 'is freshclam by default' do 154 | expect(Clamby.config[:executable_path_freshclam]).to eq 'freshclam' 155 | end 156 | it 'accepts an executable_path_freshclam option in the config' do 157 | path = '/custom/path/freshclam' 158 | Clamby.configure(executable_path_freshclam: path) 159 | expect(Clamby.config[:executable_path_freshclam]).to eq path 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/fixtures/safe (special).txt: -------------------------------------------------------------------------------- 1 | This is a virus-free file. 2 | It is used by automated tests. 3 | -------------------------------------------------------------------------------- /spec/fixtures/safe.txt: -------------------------------------------------------------------------------- 1 | This is a virus-free file. 2 | It is used by automated tests. 3 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.setup 3 | 4 | require 'open-uri' 5 | require 'tempfile' 6 | 7 | require 'clamby' # and any other gems you need 8 | 9 | RSpec.configure do |config| 10 | config.mock_with :rspec do |mocks| 11 | # so that Command can keep doing what it always does. 12 | mocks.verify_partial_doubles = true 13 | end 14 | 15 | def download(url) 16 | file = URI.open(url) 17 | file.is_a?(StringIO) ? to_tempfile(file) : file 18 | end 19 | 20 | # OpenURI returns either Tempfile or StringIO depending of the size of 21 | # the response. We want to unify this and always return Tempfile. 22 | def to_tempfile(io) 23 | tempfile = Tempfile.new('tmp') 24 | tempfile.binmode 25 | ::OpenURI::Meta.init(tempfile, io) 26 | tempfile << io.string 27 | tempfile.rewind 28 | tempfile 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/shared_context.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'paths' do 2 | let(:special_path) { File.expand_path('../../fixtures/safe (special).txt', __FILE__) } 3 | let(:good_path) { File.expand_path('../../fixtures/safe.txt', __FILE__) } 4 | let(:bad_path) { File.expand_path("not-here/#{rand 10e6}.txt", __FILE__) } 5 | end 6 | --------------------------------------------------------------------------------