ratonvirus 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|