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.
@@ -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