mihari 7.1.1 → 7.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ea0adf73bba53f264c22f8885d44e24de5657a2a77c2b7ea3533bb5acf6e78b
4
- data.tar.gz: 77ab077d9322a22b0e399c81c057485aec1c4cdb1a14f15cbd81f1a3650f37a6
3
+ metadata.gz: d7463506c12a2476cfea5dda939e88e01150fb6539c232e102c15fca4c57ab99
4
+ data.tar.gz: fdb700f461140badc1d90b3199999a97e483e57ae9c9bfed2d0157a94f35529d
5
5
  SHA512:
6
- metadata.gz: 3b68702c146819189140c0c5626c27f7b53f94021dd21138f0ac2758366486cebb7c450e0168f76cfc3d9940ee86eb2b279eb1725a3922614f51930dcb0e6b71
7
- data.tar.gz: e30c01360a6d382e73a8e88a40a5cd3f1dba6bac9ebbbe9b87b9b51cff4a6dc59676d19eec4fe757a3a40d78a560886e434802b74fdf125e663b73ae4c7abd8a
6
+ metadata.gz: 18d9dd1f63056cd2731caa823904fd9df80ca21e63a540eab89d06e7951fa10cde498344049f3ba7de8740da2e8cfb9da9fe660f77c3a1abca5e308e36a6973c
7
+ data.tar.gz: 5a8ce97455cb0e68538af1ca2c8af5b89ea96f817d4f11cf6c00851035a20288f9bc2d2de16b58b52f3e72e988c77a2e4d8744a3fbebfd27c2cb6895d4c4dccb
data/Rakefile CHANGED
@@ -50,14 +50,15 @@ namespace :build do
50
50
  end
51
51
  end
52
52
 
53
- def ci?
54
- ENV.fetch("CI", false)
55
- end
56
-
57
- unless ci?
58
- task :build do
59
- sh "./build_frontend.sh"
60
- end
53
+ task :build do
54
+ # Build Swagger dos
55
+ Rake::Task["build:swagger"].invoke
56
+ # Build ReDocs docs & frontend assets
57
+ sh "cd frontend && npm install && npm run docs && npm run build-only"
58
+ # Copy built assets into ./lib/web/public/
59
+ sh "rm -rf ./lib/mihari/web/public/"
60
+ sh "mkdir -p ./lib/mihari/web/public/"
61
+ sh "cp -r frontend/dist/* ./lib/mihari/web/public"
61
62
  end
62
63
 
63
64
  # require it later enables doing pre-build step (= build the frontend app)
data/build_frontend.sh CHANGED
@@ -3,7 +3,7 @@
3
3
  CURRENT_DIR=${PWD}
4
4
 
5
5
  cd frontend
6
- npm ci
6
+ npm install
7
7
  npm run build
8
8
 
9
9
  trash -r ${CURRENT_DIR}/lib/mihari/web/public/
data/lib/mihari/actor.rb CHANGED
@@ -53,10 +53,10 @@ module Mihari
53
53
  def validate_configuration!
54
54
  return if configured?
55
55
 
56
- joined = self.class.configuration_keys.join(", ")
57
- be = (self.class.configuration_keys.length > 1) ? "are" : "is"
58
- message = "#{self.class.class_key} is not configured correctly. #{joined} #{be} missing."
59
- raise ConfigurationError, message
56
+ message = "#{self.class.type.capitalize}:#{self.class.key} is not configured correctly"
57
+ detail = self.class.configuration_keys.map { |key| "#{key.upcase} is missing" }
58
+
59
+ raise ConfigurationError.new(message, detail)
60
60
  end
61
61
 
62
62
  def call(*args, **kwargs)
@@ -75,22 +75,30 @@ module Mihari
75
75
  #
76
76
  # @return [String]
77
77
  #
78
- def class_key
78
+ def key
79
79
  to_s.split("::").last.downcase
80
80
  end
81
81
 
82
82
  #
83
83
  # @return [Array<String>, nil]
84
84
  #
85
- def class_key_aliases
85
+ def key_aliases
86
86
  nil
87
87
  end
88
88
 
89
89
  #
90
90
  # @return [Array<String>]
91
91
  #
92
- def class_keys
93
- ([class_key] + [class_key_aliases]).flatten.compact.map(&:downcase)
92
+ def keys
93
+ ([key] + [key_aliases]).flatten.compact.map(&:downcase)
94
+ end
95
+
96
+ def type
97
+ return "analyzer" if ancestors.include?(Mihari::Analyzers::Base)
98
+ return "emitter" if ancestors.include?(Mihari::Emitters::Base)
99
+ return "enricher" if ancestors.include?(Mihari::Enrichers::Base)
100
+
101
+ nil
94
102
  end
95
103
  end
96
104
  end
@@ -65,7 +65,7 @@ module Mihari
65
65
  # It is set automatically in #initialize
66
66
  artifact = artifact.is_a?(Models::Artifact) ? artifact : Models::Artifact.new(data: artifact)
67
67
 
68
- artifact.source = self.class.class_key
68
+ artifact.source = self.class.key
69
69
  artifact.query = query
70
70
 
71
71
  artifact
@@ -93,14 +93,23 @@ module Mihari
93
93
  return result if result.success?
94
94
 
95
95
  # Wrap failure with AnalyzerError to explicitly name a failed analyzer
96
- error = AnalyzerError.new(result.failure.message, self.class.class_key, cause: result.failure)
96
+ error = AnalyzerError.new(result.failure.message, self.class.key, cause: result.failure)
97
97
  return Failure(error) unless ignore_error?
98
98
 
99
99
  # Return Success if ignore_error? is true with logging
100
- Mihari.logger.warn("Analyzer:#{self.class.class_key} failed - #{result.failure}")
100
+ Mihari.logger.warn("Analyzer:#{self.class.key} with #{truncated_query} failed - #{result.failure}")
101
101
  Success([])
102
102
  end
103
103
 
104
+ #
105
+ # Truncate query for logging
106
+ #
107
+ # @return [String]
108
+ #
109
+ def truncated_query
110
+ query.truncate(32)
111
+ end
112
+
104
113
  class << self
105
114
  #
106
115
  # Initialize an analyzer by query params
@@ -57,7 +57,7 @@ module Mihari
57
57
  #
58
58
  # @return [Array<String>, nil]
59
59
  #
60
- def class_key_aliases
60
+ def key_aliases
61
61
  ["pt"]
62
62
  end
63
63
  end
@@ -51,7 +51,7 @@ module Mihari
51
51
  #
52
52
  # @return [Array<String>, nil]
53
53
  #
54
- def class_key_aliases
54
+ def key_aliases
55
55
  ["st"]
56
56
  end
57
57
  end
@@ -46,7 +46,7 @@ module Mihari
46
46
  #
47
47
  # @return [Array<String>, nil]
48
48
  #
49
- def class_key_aliases
49
+ def key_aliases
50
50
  ["vt"]
51
51
  end
52
52
  end
@@ -34,14 +34,14 @@ module Mihari
34
34
  #
35
35
  # @return [String]
36
36
  #
37
- def class_key
37
+ def key
38
38
  "virustotal_intelligence"
39
39
  end
40
40
 
41
41
  #
42
42
  # @return [Array<String>, nil]
43
43
  #
44
- def class_key_aliases
44
+ def key_aliases
45
45
  ["vt_intel"]
46
46
  end
47
47
  end
@@ -10,10 +10,10 @@ module Mihari
10
10
  def included(thor)
11
11
  thor.class_eval do
12
12
  desc "web", "Start the web app"
13
- method_option :port, type: :numeric, default: 9292, desc: "Hostname to listen on"
14
- method_option :host, type: :string, default: "localhost", desc: "Port to listen on"
13
+ method_option :port, type: :numeric, default: 9292, desc: "Port to listen on"
14
+ method_option :host, type: :string, default: "localhost", desc: "Hostname to listen on"
15
15
  method_option :threads, type: :string, default: "0:5", desc: "min:max threads to use"
16
- method_option :verbose, type: :boolean, default: true, desc: "Report each request"
16
+ method_option :verbose, type: :boolean, default: false, desc: "Don't report each request"
17
17
  method_option :worker_timeout, type: :numeric, default: 60, desc: "Worker timeout value (in seconds)"
18
18
  method_option :open, type: :boolean, default: true, desc: "Whether to open the app in browser or not"
19
19
  method_option :env, type: :string, default: "production", desc: "Environment"
@@ -5,7 +5,7 @@ module Mihari
5
5
  DEFAULT_DATA_TYPES = Types::DataTypes.values.freeze
6
6
 
7
7
  # @return [Array<Hash>]
8
- DEFAULT_EMITTERS = Emitters::Database.class_keys.map { |name| { emitter: name.downcase } }.freeze
8
+ DEFAULT_EMITTERS = Emitters::Database.keys.map { |name| { emitter: name.downcase } }.freeze
9
9
 
10
10
  # @return [Array<Hash>]
11
11
  DEFAULT_ENRICHERS = Mihari.enricher_to_class.keys.map { |name| { enricher: name.downcase } }.freeze
@@ -19,6 +19,14 @@ module Mihari
19
19
  @rule = rule
20
20
  end
21
21
 
22
+ # A target to emit the data
23
+ #
24
+ # @return [String]
25
+ #
26
+ def target
27
+ raise NotImplementedError, "You must implement #{self.class}##{__method__}"
28
+ end
29
+
22
30
  #
23
31
  # @param [Array<Mihari::Models::Artifact>] artifacts
24
32
  #
@@ -38,7 +46,9 @@ module Mihari
38
46
  ) { call(artifacts) }
39
47
  end.to_result
40
48
 
41
- Mihari.logger.warn("Emitter:#{self.class.class_key} failed - #{result.failure}") if result.failure?
49
+ if result.failure?
50
+ Mihari.logger.warn("Emitter:#{self.class.key} for #{target.truncate(32)} failed - #{result.failure}")
51
+ end
42
52
 
43
53
  result
44
54
  end
@@ -21,6 +21,10 @@ module Mihari
21
21
  alert
22
22
  end
23
23
 
24
+ def target
25
+ Mihari.config.database_url.host || Mihari.config.database_url.to_s
26
+ end
27
+
24
28
  class << self
25
29
  def configuration_keys
26
30
  %w[database_url]
@@ -56,6 +56,13 @@ module Mihari
56
56
  })
57
57
  end
58
58
 
59
+ #
60
+ # @return [String]
61
+ #
62
+ def target
63
+ URI(url).host || "N/A"
64
+ end
65
+
59
66
  class << self
60
67
  def configuration_keys
61
68
  %w[misp_url misp_api_key]
@@ -165,6 +165,13 @@ module Mihari
165
165
  webhook_url?
166
166
  end
167
167
 
168
+ #
169
+ # @return [String]
170
+ #
171
+ def target
172
+ channel
173
+ end
174
+
168
175
  #
169
176
  # @return [::Slack::Notifier]
170
177
  #
@@ -33,6 +33,13 @@ module Mihari
33
33
  api_key? && url?
34
34
  end
35
35
 
36
+ #
37
+ # @return [String]
38
+ #
39
+ def target
40
+ URI(url).host || "N/A"
41
+ end
42
+
36
43
  #
37
44
  # Create a Hive alert
38
45
  #
@@ -55,6 +55,13 @@ module Mihari
55
55
  %w[http https].include? url.scheme.downcase
56
56
  end
57
57
 
58
+ #
59
+ # @return [String]
60
+ #
61
+ def target
62
+ URI(url).host || "N/A"
63
+ end
64
+
58
65
  #
59
66
  # @param [Array<Mihari::Models::Artifact>] artifacts
60
67
  #
@@ -33,7 +33,9 @@ module Mihari
33
33
  ) { call value }
34
34
  end.to_result
35
35
 
36
- Mihari.logger.warn("Enricher:#{self.class.class_key} failed: #{result.failure}") if result.failure?
36
+ if result.failure?
37
+ Mihari.logger.warn("Enricher:#{self.class.key} for #{value.truncate(32)} failed: #{result.failure}")
38
+ end
37
39
 
38
40
  result
39
41
  end
@@ -21,7 +21,7 @@ module Mihari
21
21
  #
22
22
  # @return [String]
23
23
  #
24
- def class_key
24
+ def key
25
25
  "google_public_dns"
26
26
  end
27
27
  end
data/lib/mihari/errors.rb CHANGED
@@ -7,7 +7,20 @@ module Mihari
7
7
 
8
8
  class ValueError < Error; end
9
9
 
10
- class ConfigurationError < Error; end
10
+ class ConfigurationError < Error
11
+ # @return [Array<String>, nil]
12
+ attr_reader :detail
13
+
14
+ #
15
+ # @param [String] msg
16
+ # @param [Array<String>, nil] detail
17
+ #
18
+ def initialize(msg, detail)
19
+ super(msg)
20
+
21
+ @detail = detail
22
+ end
23
+ end
11
24
 
12
25
  class ResponseError < Error; end
13
26
 
@@ -10,12 +10,12 @@ module Mihari
10
10
 
11
11
  # Analyzer with API key and pagination
12
12
  [
13
- Mihari::Analyzers::BinaryEdge.class_keys,
14
- Mihari::Analyzers::GreyNoise.class_keys,
15
- Mihari::Analyzers::Onyphe.class_keys,
16
- Mihari::Analyzers::Shodan.class_keys,
17
- Mihari::Analyzers::Urlscan.class_keys,
18
- Mihari::Analyzers::VirusTotalIntelligence.class_keys
13
+ Mihari::Analyzers::BinaryEdge.keys,
14
+ Mihari::Analyzers::GreyNoise.keys,
15
+ Mihari::Analyzers::Onyphe.keys,
16
+ Mihari::Analyzers::Shodan.keys,
17
+ Mihari::Analyzers::Urlscan.keys,
18
+ Mihari::Analyzers::VirusTotalIntelligence.keys
19
19
  ].each do |keys|
20
20
  key = keys.first
21
21
  const_set(key.upcase, Dry::Schema.Params do
@@ -28,10 +28,10 @@ module Mihari
28
28
 
29
29
  # Analyzer with API key
30
30
  [
31
- Mihari::Analyzers::OTX.class_keys,
32
- Mihari::Analyzers::Pulsedive.class_keys,
33
- Mihari::Analyzers::VirusTotal.class_keys,
34
- Mihari::Analyzers::SecurityTrails.class_keys
31
+ Mihari::Analyzers::OTX.keys,
32
+ Mihari::Analyzers::Pulsedive.keys,
33
+ Mihari::Analyzers::VirusTotal.keys,
34
+ Mihari::Analyzers::SecurityTrails.keys
35
35
  ].each do |keys|
36
36
  key = keys.first
37
37
  const_set(key.upcase, Dry::Schema.Params do
@@ -43,13 +43,13 @@ module Mihari
43
43
  end
44
44
 
45
45
  DNSTwister = Dry::Schema.Params do
46
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::DNSTwister.class_keys))
46
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::DNSTwister.keys))
47
47
  required(:query).value(:string)
48
48
  optional(:options).hash(AnalyzerOptions)
49
49
  end
50
50
 
51
51
  Censys = Dry::Schema.Params do
52
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Censys.class_keys))
52
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Censys.keys))
53
53
  required(:query).value(:string)
54
54
  optional(:id).value(:string)
55
55
  optional(:secret).value(:string)
@@ -57,7 +57,7 @@ module Mihari
57
57
  end
58
58
 
59
59
  CIRCL = Dry::Schema.Params do
60
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::CIRCL.class_keys))
60
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::CIRCL.keys))
61
61
  required(:query).value(:string)
62
62
  optional(:username).value(:string)
63
63
  optional(:password).value(:string)
@@ -65,7 +65,7 @@ module Mihari
65
65
  end
66
66
 
67
67
  Fofa = Dry::Schema.Params do
68
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Fofa.class_keys))
68
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Fofa.keys))
69
69
  required(:query).value(:string)
70
70
  optional(:api_key).value(:string)
71
71
  optional(:email).value(:string)
@@ -73,7 +73,7 @@ module Mihari
73
73
  end
74
74
 
75
75
  PassiveTotal = Dry::Schema.Params do
76
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::PassiveTotal.class_keys))
76
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::PassiveTotal.keys))
77
77
  required(:query).value(:string)
78
78
  optional(:username).value(:string)
79
79
  optional(:api_key).value(:string)
@@ -81,14 +81,14 @@ module Mihari
81
81
  end
82
82
 
83
83
  ZoomEye = Dry::Schema.Params do
84
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::ZoomEye.class_keys))
84
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::ZoomEye.keys))
85
85
  required(:query).value(:string)
86
86
  required(:type).value(Types::String.enum("host", "web"))
87
87
  optional(:options).hash(AnalyzerPaginationOptions)
88
88
  end
89
89
 
90
90
  Crtsh = Dry::Schema.Params do
91
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Crtsh.class_keys))
91
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Crtsh.keys))
92
92
  required(:query).value(:string)
93
93
  optional(:exclude_expired).value(:bool).default(true)
94
94
  optional(:match).value(Types::String.enum("=", "ILIKE", "LIKE", "single", "any", "FTS")).default(nil)
@@ -96,7 +96,7 @@ module Mihari
96
96
  end
97
97
 
98
98
  HunterHow = Dry::Schema.Params do
99
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::HunterHow.class_keys))
99
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::HunterHow.keys))
100
100
  required(:query).value(:string)
101
101
  required(:start_time).value(:date)
102
102
  required(:end_time).value(:date)
@@ -105,7 +105,7 @@ module Mihari
105
105
  end
106
106
 
107
107
  Feed = Dry::Schema.Params do
108
- required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Feed.class_keys))
108
+ required(:analyzer).value(Types::String.enum(*Mihari::Analyzers::Feed.keys))
109
109
  required(:query).value(:string)
110
110
  required(:selector).value(:string)
111
111
  optional(:method).value(Types::HTTPRequestMethods).default("GET")
@@ -9,33 +9,33 @@ module Mihari
9
9
  extend Concerns::Orrable
10
10
 
11
11
  Database = Dry::Schema.Params do
12
- required(:emitter).value(Types::String.enum(*Mihari::Emitters::Database.class_keys))
12
+ required(:emitter).value(Types::String.enum(*Mihari::Emitters::Database.keys))
13
13
  optional(:options).hash(Options)
14
14
  end
15
15
 
16
16
  MISP = Dry::Schema.Params do
17
- required(:emitter).value(Types::String.enum(*Mihari::Emitters::MISP.class_keys))
17
+ required(:emitter).value(Types::String.enum(*Mihari::Emitters::MISP.keys))
18
18
  optional(:url).value(:string)
19
19
  optional(:api_key).value(:string)
20
20
  optional(:options).hash(Options)
21
21
  end
22
22
 
23
23
  TheHive = Dry::Schema.Params do
24
- required(:emitter).value(Types::String.enum(*Mihari::Emitters::TheHive.class_keys))
24
+ required(:emitter).value(Types::String.enum(*Mihari::Emitters::TheHive.keys))
25
25
  optional(:url).value(:string)
26
26
  optional(:api_key).value(:string)
27
27
  optional(:options).hash(Options)
28
28
  end
29
29
 
30
30
  Slack = Dry::Schema.Params do
31
- required(:emitter).value(Types::String.enum(*Mihari::Emitters::Slack.class_keys))
31
+ required(:emitter).value(Types::String.enum(*Mihari::Emitters::Slack.keys))
32
32
  optional(:webhook_url).value(:string)
33
33
  optional(:channel).value(:string)
34
34
  optional(:options).hash(Options)
35
35
  end
36
36
 
37
37
  Webhook = Dry::Schema.Params do
38
- required(:emitter).value(Types::String.enum(*Mihari::Emitters::Webhook.class_keys))
38
+ required(:emitter).value(Types::String.enum(*Mihari::Emitters::Webhook.keys))
39
39
  required(:url).value(:string)
40
40
  optional(:method).value(Types::HTTPRequestMethods).default("POST")
41
41
  optional(:headers).value(:hash).default({})
@@ -9,22 +9,22 @@ module Mihari
9
9
  extend Concerns::Orrable
10
10
 
11
11
  MMDB = Dry::Schema.Params do
12
- required(:enricher).value(Types::String.enum(*Mihari::Enrichers::MMDB.class_keys))
12
+ required(:enricher).value(Types::String.enum(*Mihari::Enrichers::MMDB.keys))
13
13
  optional(:options).hash(Options)
14
14
  end
15
15
 
16
16
  Whois = Dry::Schema.Params do
17
- required(:enricher).value(Types::String.enum(*Mihari::Enrichers::Whois.class_keys))
17
+ required(:enricher).value(Types::String.enum(*Mihari::Enrichers::Whois.keys))
18
18
  optional(:options).hash(Options)
19
19
  end
20
20
 
21
21
  Shodan = Dry::Schema.Params do
22
- required(:enricher).value(Types::String.enum(*Mihari::Enrichers::Shodan.class_keys))
22
+ required(:enricher).value(Types::String.enum(*Mihari::Enrichers::Shodan.keys))
23
23
  optional(:options).hash(Options)
24
24
  end
25
25
 
26
26
  GooglePublicDNS = Dry::Schema.Params do
27
- required(:enricher).value(Types::String.enum(*Mihari::Enrichers::GooglePublicDNS.class_keys))
27
+ required(:enricher).value(Types::String.enum(*Mihari::Enrichers::GooglePublicDNS.keys))
28
28
  optional(:options).hash(Options)
29
29
  end
30
30
  end
@@ -20,21 +20,6 @@ module Mihari
20
20
  attribute :items, Types.Array(Types::Hash).optional
21
21
 
22
22
  class << self
23
- #
24
- # Get a type of a class
25
- #
26
- # @param [Class<Mihari::Analyzers::Base>, Class<Mihari::Emitters::Base>, Class<Mihari::Enrichers::Base>] klass
27
- #
28
- # @return [String, nil]
29
- #
30
- def get_type(klass)
31
- return "analyzer" if klass.ancestors.include?(Mihari::Analyzers::Base)
32
- return "emitter" if klass.ancestors.include?(Mihari::Emitters::Base)
33
- return "enricher" if klass.ancestors.include?(Mihari::Enrichers::Base)
34
-
35
- nil
36
- end
37
-
38
23
  #
39
24
  # Get a dummy instance
40
25
  #
@@ -43,8 +28,7 @@ module Mihari
43
28
  # @return [Mihari::Analyzers::Base, Mihari::Emitter::Base, Mihari::Enricher::Base] dummy
44
29
  #
45
30
  def get_dummy(klass)
46
- type = get_type(klass)
47
- case type
31
+ case klass.type
48
32
  when "analyzer"
49
33
  klass.new("dummy")
50
34
  when "emitter"
@@ -62,16 +46,15 @@ module Mihari
62
46
  def from_class(klass)
63
47
  return nil if klass == Mihari::Rule
64
48
 
65
- type = get_type(klass)
66
- return nil if type.nil?
49
+ return nil if klass.type.nil?
67
50
 
68
51
  begin
69
52
  instance = get_dummy(klass)
70
53
  new(
71
- name: klass.class_key,
54
+ name: klass.key,
72
55
  items: klass.configuration_items,
73
56
  configured: instance.configured?,
74
- type: type
57
+ type: klass.type
75
58
  )
76
59
  rescue ArgumentError
77
60
  nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mihari
4
- VERSION = "7.1.1"
4
+ VERSION = "7.1.3"
5
5
  end