ratonvirus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE +22 -0
- data/README.md +247 -0
- data/app/validators/antivirus_validator.rb +26 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/fi.yml +6 -0
- data/config/locales/sv.yml +6 -0
- data/lib/ratonvirus.rb +109 -0
- data/lib/ratonvirus/engine.rb +6 -0
- data/lib/ratonvirus/error.rb +7 -0
- data/lib/ratonvirus/processable.rb +17 -0
- data/lib/ratonvirus/scanner/addon/remove_infected.rb +18 -0
- data/lib/ratonvirus/scanner/base.rb +154 -0
- data/lib/ratonvirus/scanner/eicar.rb +34 -0
- data/lib/ratonvirus/scanner/support/callbacks.rb +84 -0
- data/lib/ratonvirus/storage/active_storage.rb +96 -0
- data/lib/ratonvirus/storage/base.rb +56 -0
- data/lib/ratonvirus/storage/carrierwave.rb +36 -0
- data/lib/ratonvirus/storage/filepath.rb +46 -0
- data/lib/ratonvirus/storage/multi.rb +78 -0
- data/lib/ratonvirus/support/backend.rb +181 -0
- data/lib/ratonvirus/version.rb +5 -0
- data/lib/tasks/ratonvirus.rake +40 -0
- metadata +179 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module Ratonvirus
|
2
|
+
class Processable
|
3
|
+
def initialize(storage, asset)
|
4
|
+
@storage = storage
|
5
|
+
@asset = asset
|
6
|
+
end
|
7
|
+
|
8
|
+
def path(&block)
|
9
|
+
return unless block_given?
|
10
|
+
@storage.asset_path(@asset, &block)
|
11
|
+
end
|
12
|
+
|
13
|
+
def remove
|
14
|
+
@storage.asset_remove(@asset)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Ratonvirus
|
2
|
+
module Scanner
|
3
|
+
module Addon
|
4
|
+
module RemoveInfected
|
5
|
+
def self.extended(validator)
|
6
|
+
validator.after_scan :remove_infected_file
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
def remove_infected_file(processable)
|
11
|
+
return unless errors.include?(:antivirus_virus_detected)
|
12
|
+
|
13
|
+
processable.remove
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module Ratonvirus
|
2
|
+
module Scanner
|
3
|
+
class Base
|
4
|
+
include Support::Callbacks
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def executable?
|
8
|
+
false
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :config
|
13
|
+
attr_reader :errors # Only available after `virus?` has been called.
|
14
|
+
|
15
|
+
def initialize(configuration={})
|
16
|
+
@config = default_config.merge!(configuration)
|
17
|
+
|
18
|
+
# Make the following callbacks available:
|
19
|
+
# - before_process_scan
|
20
|
+
# - before_scan
|
21
|
+
# - after_scan
|
22
|
+
# - after_process_scan
|
23
|
+
#
|
24
|
+
# Usage:
|
25
|
+
# module CustomAddon
|
26
|
+
# def self.extended(validator)
|
27
|
+
# validator.before_process_scan :around_scan
|
28
|
+
# validator.before_scan :do_something
|
29
|
+
# validator.after_scan :do_something
|
30
|
+
# validator.before_process_scan :around_scan
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# private
|
34
|
+
# def around_scan(resource)
|
35
|
+
# puts resource.inspect
|
36
|
+
# # Depends on the provided resource, e.g.
|
37
|
+
# # => #<ActiveStorage::Attached::One: ...>
|
38
|
+
# # => #<ActiveStorage::Attached::Many: ...>
|
39
|
+
# # => #<CarrierWave::Uploader::Base: ...>
|
40
|
+
# # => #<File: ...>
|
41
|
+
# # => #<String: ...>
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# def do_something(processable)
|
45
|
+
# puts processable.inspect
|
46
|
+
# # => #<Ratonvirus::Processable: ...>
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
define_callbacks :process_scan # Around the scan for the whole resource
|
50
|
+
define_callbacks :scan # The actual scan for individual assets
|
51
|
+
|
52
|
+
setup
|
53
|
+
end
|
54
|
+
|
55
|
+
# This method can be overridden in the scanner implementations in case
|
56
|
+
# the setup needs to be customized.
|
57
|
+
def setup
|
58
|
+
if config[:force_availability]
|
59
|
+
@available = true
|
60
|
+
else
|
61
|
+
available?
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def available?
|
66
|
+
return @available unless @available.nil?
|
67
|
+
|
68
|
+
@available = self.class.executable?
|
69
|
+
end
|
70
|
+
|
71
|
+
# The virus? method runs the scan and returns a boolean indicating
|
72
|
+
# whether the scanner rejected the given resource or detected a virus.
|
73
|
+
# Scanning is mainly used to detect viruses but the scanner can reject the
|
74
|
+
# resource also because of other reasons than it detecting a virus.
|
75
|
+
#
|
76
|
+
# All these cases, however, should be interpreted as the resource
|
77
|
+
# containing a virus because in case there is e.g. a client error, we
|
78
|
+
# cannot be sure whether the file contains a virus and therefore it's
|
79
|
+
# safer to assume the worst.
|
80
|
+
#
|
81
|
+
# Possible errors are:
|
82
|
+
# - :antivirus_virus_detected - A virus was detected.
|
83
|
+
# - :antivirus_file_not_found - The scanner did not find the file for the
|
84
|
+
# given resource.
|
85
|
+
# - :antivirus_client_error - There was a client error at the scanner,
|
86
|
+
# e.g. it is temporarily unavailable.
|
87
|
+
def virus?(resource)
|
88
|
+
prepare
|
89
|
+
|
90
|
+
@errors = []
|
91
|
+
|
92
|
+
run_callbacks :process_scan, resource do
|
93
|
+
storage.process(resource) do |processable|
|
94
|
+
# In case multiple processables are processed, make sure that the
|
95
|
+
# local errors for each scan refer only to that scan.
|
96
|
+
errors_before = @errors
|
97
|
+
@errors = []
|
98
|
+
|
99
|
+
begin
|
100
|
+
scan(processable)
|
101
|
+
ensure
|
102
|
+
# Make sure that after the scan, the errors are reverted back to
|
103
|
+
# all errors.
|
104
|
+
@errors = errors_before + @errors
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Only show unique errors
|
110
|
+
errors.uniq!
|
111
|
+
|
112
|
+
errors.any?
|
113
|
+
end
|
114
|
+
|
115
|
+
protected
|
116
|
+
def default_config
|
117
|
+
{
|
118
|
+
force_availability: false,
|
119
|
+
}
|
120
|
+
end
|
121
|
+
|
122
|
+
def storage
|
123
|
+
Ratonvirus.storage
|
124
|
+
end
|
125
|
+
|
126
|
+
def scan(processable)
|
127
|
+
processable.path do |path|
|
128
|
+
run_callbacks :scan, processable do
|
129
|
+
run_scan(path)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def run_scan(path)
|
135
|
+
raise NotImplementedError.new(
|
136
|
+
"Implement run_scan on #{self.class.name}"
|
137
|
+
)
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
# Prepare is called each time before scanning is run. During the first
|
142
|
+
# call to this method, the addons are applied to the scanner instance.
|
143
|
+
def prepare
|
144
|
+
return if @ready
|
145
|
+
|
146
|
+
Ratonvirus.addons.each do |addon_cls|
|
147
|
+
extend addon_cls
|
148
|
+
end
|
149
|
+
|
150
|
+
@ready = true
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ratonvirus
|
4
|
+
module Scanner
|
5
|
+
# Dummy EICAR file scanner to test the integration with this gem.
|
6
|
+
#
|
7
|
+
# Only to be used for testing the functionality of this gem.
|
8
|
+
class Eicar < Base
|
9
|
+
# SHA256 digest of the EICAR test file for virus testing
|
10
|
+
# See: https://en.wikipedia.org/wiki/EICAR_test_file
|
11
|
+
EICAR_SHA256 = '131f95c51cc819465fa1797f6ccacf9d494aaaff46fa3eac73ae63ffbdfd8267'
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def executable?
|
15
|
+
true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
def run_scan(path)
|
21
|
+
if !File.file?(path)
|
22
|
+
errors << :antivirus_file_not_found
|
23
|
+
else
|
24
|
+
sha256 = Digest::SHA256.file path
|
25
|
+
if sha256 == EICAR_SHA256
|
26
|
+
errors << :antivirus_virus_detected
|
27
|
+
end
|
28
|
+
end
|
29
|
+
rescue
|
30
|
+
errors << :antivirus_client_error
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Ratonvirus
|
2
|
+
module Scanner
|
3
|
+
module Support
|
4
|
+
# Provides a simple callbacks implementation to be used with the scanners.
|
5
|
+
#
|
6
|
+
# We cannot use the ActiveSupport::Callbacks because that applies the
|
7
|
+
# callbacks to the whole class. We only want to define callbacks on the
|
8
|
+
# single instance of a class.
|
9
|
+
#
|
10
|
+
# Defining new callbacks hooks to the instance:
|
11
|
+
# class Cls
|
12
|
+
# include Ratonvirus::Support::Callbacks
|
13
|
+
#
|
14
|
+
# def initialize
|
15
|
+
# define_callbacks :hook
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# Triggering the callback hooks:
|
20
|
+
# class Cls
|
21
|
+
# # ...
|
22
|
+
# def some_method
|
23
|
+
# run_callbacks :hook, resource do
|
24
|
+
# puts "... do something ..."
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
# # ...
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# Applying functionality to the hooks:
|
31
|
+
# class Cls
|
32
|
+
# def attach_callbacks
|
33
|
+
# before_hook :run_before
|
34
|
+
# after_hook :run_after
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# def run_before
|
38
|
+
# puts "This is run before the hook"
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# def run_after
|
42
|
+
# puts "This is run after the hook"
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
module Callbacks
|
46
|
+
private
|
47
|
+
def run_callbacks(type, *args, &block)
|
48
|
+
if @_callbacks.nil?
|
49
|
+
raise NotDefinedError.new("No callbacks defined")
|
50
|
+
end
|
51
|
+
if @_callbacks[type].nil?
|
52
|
+
raise NotDefinedError.new("Callbacks for #{type} not defined")
|
53
|
+
end
|
54
|
+
|
55
|
+
run_callback_callables @_callbacks[type][:before], *args
|
56
|
+
result = yield *args
|
57
|
+
run_callback_callables @_callbacks[type][:after], *args
|
58
|
+
|
59
|
+
result
|
60
|
+
end
|
61
|
+
|
62
|
+
def run_callback_callables(callables, *args)
|
63
|
+
callables.each do |callable|
|
64
|
+
send(callable, *args)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def define_callbacks(type)
|
69
|
+
@_callbacks ||= {}
|
70
|
+
@_callbacks[type] ||= {}
|
71
|
+
@_callbacks[type][:before] = []
|
72
|
+
@_callbacks[type][:after] = []
|
73
|
+
|
74
|
+
define_singleton_method "before_#{type}" do |callable|
|
75
|
+
@_callbacks[type][:before] << callable
|
76
|
+
end
|
77
|
+
define_singleton_method "after_#{type}" do |callable|
|
78
|
+
@_callbacks[type][:after] << callable
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ratonvirus
|
4
|
+
module Storage
|
5
|
+
class ActiveStorage < Base
|
6
|
+
def changed?(record, attribute)
|
7
|
+
# With Active Storage we assume the record is always changed because
|
8
|
+
# there is currently no way to know if the attribute has actually
|
9
|
+
# changed.
|
10
|
+
#
|
11
|
+
# Calling record.changed? will not also work because it is not marked
|
12
|
+
# as dirty in case the Active Storage attachment has changed.
|
13
|
+
#
|
14
|
+
# NOTE:
|
15
|
+
# This should be changed in the future as the `attachment_changes` was
|
16
|
+
# introduced to Rails by this commit:
|
17
|
+
# https://github.com/rails/rails/commit/e8682c5bf051517b0b265e446aa1a7eccfd47bf7
|
18
|
+
#
|
19
|
+
# However, it is still not available in Rails 5.2.x.
|
20
|
+
true
|
21
|
+
end
|
22
|
+
|
23
|
+
def accept?(resource)
|
24
|
+
resource.is_a?(::ActiveStorage::Attached::One) ||
|
25
|
+
resource.is_a?(::ActiveStorage::Attached::Many)
|
26
|
+
end
|
27
|
+
|
28
|
+
def process(resource, &block)
|
29
|
+
return unless block_given?
|
30
|
+
return if resource.nil?
|
31
|
+
|
32
|
+
if resource.attached?
|
33
|
+
if resource.is_a?(::ActiveStorage::Attached::One)
|
34
|
+
if resource.attachment
|
35
|
+
yield processable(resource.attachment)
|
36
|
+
end
|
37
|
+
elsif resource.is_a?(::ActiveStorage::Attached::Many)
|
38
|
+
resource.attachments.each do |attachment|
|
39
|
+
yield processable(attachment)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def asset_path(asset, &block)
|
46
|
+
return unless block_given?
|
47
|
+
return if asset.nil?
|
48
|
+
return unless asset.blob
|
49
|
+
|
50
|
+
blob_path asset.blob, &block
|
51
|
+
end
|
52
|
+
|
53
|
+
def asset_remove(asset)
|
54
|
+
asset.purge
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
# This creates a local copy of the blob for the scanning process. A
|
59
|
+
# local copy is needed for processing because the actual blob may be
|
60
|
+
# stored at a remote storage service (such as Amazon S3), meaning it
|
61
|
+
# cannot be otherwise processed locally.
|
62
|
+
#
|
63
|
+
# NOTE:
|
64
|
+
# Later implementations of Active Storage have the blob.open method that
|
65
|
+
# provides similar functionality. However, Rails 5.2.x still does not
|
66
|
+
# include this functionality, so we need to take care of it ourselves.
|
67
|
+
#
|
68
|
+
# This was introduced in the following commit:
|
69
|
+
# https://github.com/rails/rails/commit/ee21b7c2eb64def8f00887a9fafbd77b85f464f1
|
70
|
+
#
|
71
|
+
# SEE:
|
72
|
+
# https://edgeguides.rubyonrails.org/active_storage_overview.html#downloading-files
|
73
|
+
def blob_path(blob)
|
74
|
+
tempfile = Tempfile.open(
|
75
|
+
["Ratonvirus", blob.filename.extension_with_delimiter ],
|
76
|
+
tempdir
|
77
|
+
)
|
78
|
+
|
79
|
+
begin
|
80
|
+
tempfile.binmode
|
81
|
+
blob.download { |chunk| tempfile.write(chunk) }
|
82
|
+
tempfile.flush
|
83
|
+
tempfile.rewind
|
84
|
+
|
85
|
+
yield tempfile.path
|
86
|
+
ensure
|
87
|
+
tempfile.close!
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def tempdir
|
92
|
+
Dir.tmpdir
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Ratonvirus
|
2
|
+
module Storage
|
3
|
+
class Base
|
4
|
+
attr_reader :config
|
5
|
+
|
6
|
+
def initialize(configuration={})
|
7
|
+
@config = configuration.dup
|
8
|
+
|
9
|
+
if respond_to?(:setup)
|
10
|
+
setup
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Default process implementation.
|
15
|
+
def process(resource)
|
16
|
+
return unless block_given?
|
17
|
+
return if resource.nil?
|
18
|
+
|
19
|
+
resource = [resource] unless resource.is_a?(Array)
|
20
|
+
|
21
|
+
resource.each do |asset|
|
22
|
+
yield processable(asset)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def changed?(record, attribute)
|
27
|
+
raise NotImplementedError.new(
|
28
|
+
"Implement changed? on #{self.class.name}"
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def accept?(resource)
|
33
|
+
raise NotImplementedError.new(
|
34
|
+
"Implement accept? on #{self.class.name}"
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
def asset_path(asset)
|
39
|
+
raise NotImplementedError.new(
|
40
|
+
"Implement path on #{self.class.name}"
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
def asset_remove(asset)
|
45
|
+
raise NotImplementedError.new(
|
46
|
+
"Implement remove on #{self.class.name}"
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
protected
|
51
|
+
def processable(asset)
|
52
|
+
Processable.new(self, asset)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|