ratonvirus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ module Ratonvirus
2
+ class Error < StandardError; end
3
+
4
+ class InvalidError < Error; end
5
+ class NotDefinedError < Error; end
6
+ class NotImplementedError < Error; end
7
+ end
@@ -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