mihari 7.0.4 → 7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5859aa404d85ccc45008979195ace2c4104cc86336b65afacbeece226c7def5
4
- data.tar.gz: 75f6687159420d8f0d7b5f2f13fbab7f523881ad55a66574a7a9ec612cf720ae
3
+ metadata.gz: 6930da0e95068ca8e30d1f226be5692e85375f796b7246cafdd2bec566d00ff7
4
+ data.tar.gz: 2bf34b1231bffcd88d402ffda335960ad929b1de91cd51b257c9c24f7b2fc16f
5
5
  SHA512:
6
- metadata.gz: 9db820d33af592d9b2953a01e745ce05c3a297a6cbefb409e147fd90ab5d02070722abbba18b3f282bf9abcc1cc85f50d2579ab2774ea26074d74b6aea8d6073
7
- data.tar.gz: 8218aaf062e8ecad9cf01f58188de497ffba135ce42269e7ff87b82d49cf96c0a46c0aa273e752da1f97b288ffead8569fa00c8d6452701273e9694bcf4dba74
6
+ metadata.gz: fd515cdbde67d10c3fcae14c8e45a7929a34931701be176c9f9a294d3db310f522a3e186aefe548d6f119c5c8552723114da4ee6f790dbbe0391138dca9cc00e
7
+ data.tar.gz: 5f4a3e1049ad55af018cf9ed2186dda49bd18517d37bc84cad6e6f60b10589834ac3bd1f09333e7f9773fab43442400c097c1063f1c304d5be5eb8991af7ec77
@@ -92,7 +92,15 @@ module Mihari
92
92
 
93
93
  return res.recover { [] } if ignore_error?
94
94
 
95
- res.to_result
95
+ result = res.to_result
96
+ return result if result.success?
97
+
98
+ # Wrap failure with AnalyzerError to explicitly name a failed analyzer
99
+ Failure AnalyzerError.new(
100
+ result.failure.message,
101
+ self.class.class_key,
102
+ cause: result.failure
103
+ )
96
104
  end
97
105
 
98
106
  class << self
@@ -41,17 +41,20 @@ module Mihari
41
41
  def safe_execute
42
42
  yield
43
43
  rescue StandardError => e
44
- err = unwrap_error(e)
44
+ error = unwrap_error(e)
45
45
 
46
- raise err if options["debug"]
46
+ # Raise error if it's a Thor::Error to follow Thor's manner
47
+ raise error if error.is_a?(Thor::Error)
48
+ # Raise error if debug is set as true
49
+ raise error if options["debug"]
47
50
 
48
- case err
49
- when ValidationError
50
- warn JSON.pretty_generate(err.errors.to_h)
51
- when StandardError
52
- Sentry.capture_exception(err) if Sentry.initialized?
53
- warn err
54
- end
51
+ data = Entities::ErrorMessage.represent(
52
+ message: error.message,
53
+ detail: error.respond_to?(:detail) ? error.detail : nil
54
+ )
55
+ warn JSON.pretty_generate(data.as_json)
56
+
57
+ Sentry.capture_exception(error) if Sentry.initialized? && !error.is_a?(ValidationError)
55
58
 
56
59
  exit 1
57
60
  end
@@ -52,7 +52,10 @@ module Mihari
52
52
  def search(query, page:, size: PAGE_SIZE)
53
53
  qbase64 = Base64.urlsafe_encode64(query)
54
54
  params = { qbase64: qbase64, size: size, page: page, email: email, key: api_key }.compact
55
- Structs::Fofa::Response.from_dynamic! get_json("/api/v1/search/all", params: params)
55
+ res = Structs::Fofa::Response.from_dynamic!(get_json("/api/v1/search/all", params: params))
56
+ raise ResponseError, res.errmsg if res.error
57
+
58
+ res
56
59
  end
57
60
 
58
61
  #
@@ -12,6 +12,20 @@ module Mihari
12
12
  thor.class_eval do
13
13
  include Concerns::DatabaseConnectable
14
14
 
15
+ no_commands do
16
+ #
17
+ # @param [String] q
18
+ # @param [Integer] page
19
+ # @param [Integer] limit
20
+ #
21
+ # @return [Mihari::Services::ResultValue]
22
+ #
23
+ def _search(q, page: 1, limit: 10)
24
+ filter = Structs::Filters::Search.new(q: q, page: page, limit: limit)
25
+ Services::AlertSearcher.result(filter).value!
26
+ end
27
+ end
28
+
15
29
  desc "create [PATH]", "Create an alert"
16
30
  around :with_db_connection
17
31
  #
@@ -40,8 +54,7 @@ module Mihari
40
54
  # @param [String] q
41
55
  #
42
56
  def list(q = "")
43
- filter = Structs::Filters::Search.new(q: q, page: options["page"], limit: options["limit"])
44
- value = Services::AlertSearcher.result(filter).value!
57
+ value = _search(q, page: options["page"], limit: options["limit"])
45
58
  data = Entities::AlertsWithPagination.represent(
46
59
  results: value.results,
47
60
  total: value.total,
@@ -51,6 +64,28 @@ module Mihari
51
64
  puts JSON.pretty_generate(data.as_json)
52
65
  end
53
66
 
67
+ desc "list-transform QUERY", "List/search alerts with transformation"
68
+ around :with_db_connection
69
+ method_option :template, type: :string, required: true, aliases: "-t",
70
+ description: "Jbuilder template itself or a path to a template file"
71
+ method_option :page, type: :numeric, default: 1
72
+ method_option :limit, type: :numeric, default: 10
73
+ #
74
+ # @param [String] q
75
+ #
76
+ def list_transform(q = "")
77
+ value = _search(q, page: options["page"], limit: options["limit"])
78
+ puts Services::JbuilderRenderer.call(
79
+ options["template"],
80
+ {
81
+ results: value.results,
82
+ total: value.total,
83
+ current_page: value.filter[:page].to_i,
84
+ page_size: value.filter[:limit].to_i
85
+ }
86
+ )
87
+ end
88
+
54
89
  desc "get [ID]", "Get an alert"
55
90
  around :with_db_connection
56
91
  #
@@ -11,6 +11,20 @@ module Mihari
11
11
  thor.class_eval do
12
12
  include Concerns::DatabaseConnectable
13
13
 
14
+ no_commands do
15
+ #
16
+ # @param [String] q
17
+ # @param [Integer] page
18
+ # @param [Integer] limit
19
+ #
20
+ # @return [Mihari::Services::ResultValue]
21
+ #
22
+ def _search(q, page: 1, limit: 10)
23
+ filter = Structs::Filters::Search.new(q: q, page: page, limit: limit)
24
+ Services::ArtifactSearcher.result(filter).value!
25
+ end
26
+ end
27
+
14
28
  desc "list [QUERY]", "List/search artifacts"
15
29
  around :with_db_connection
16
30
  method_option :page, type: :numeric, default: 1
@@ -19,8 +33,7 @@ module Mihari
19
33
  # @param [String] q
20
34
  #
21
35
  def list(q = "")
22
- filter = Structs::Filters::Search.new(q: q, page: options["page"], limit: options["limit"])
23
- value = Services::ArtifactSearcher.result(filter).value!
36
+ value = _search(q, page: options["page"], limit: options["limit"])
24
37
  data = Entities::ArtifactsWithPagination.represent(
25
38
  results: value.results,
26
39
  total: value.total,
@@ -30,6 +43,28 @@ module Mihari
30
43
  puts JSON.pretty_generate(data.as_json)
31
44
  end
32
45
 
46
+ desc "list-transform QUERY", "List/search artifacts with transformation"
47
+ around :with_db_connection
48
+ method_option :template, type: :string, required: true, aliases: "-t",
49
+ description: "Jbuilder template itself or a path to a template file"
50
+ method_option :page, type: :numeric, default: 1
51
+ method_option :limit, type: :numeric, default: 10
52
+ #
53
+ # @param [String] q
54
+ #
55
+ def list_transform(q = "")
56
+ value = _search(q, page: options["page"], limit: options["limit"])
57
+ puts Services::JbuilderRenderer.call(
58
+ options["template"],
59
+ {
60
+ results: value.results,
61
+ total: value.total,
62
+ current_page: value.filter[:page].to_i,
63
+ page_size: value.filter[:limit].to_i
64
+ }
65
+ )
66
+ end
67
+
33
68
  desc "get [ID]", "Get an artifact"
34
69
  around :with_db_connection
35
70
  #
@@ -9,7 +9,7 @@ module Mihari
9
9
  class << self
10
10
  def included(thor)
11
11
  thor.class_eval do
12
- desc "list", "List config"
12
+ desc "list", "List configs"
13
13
  def list
14
14
  configs = Services::ConfigSearcher.call
15
15
  data = configs.map { |config| Entities::Config.represent(config) }
@@ -12,6 +12,20 @@ module Mihari
12
12
  thor.class_eval do
13
13
  include Concerns::DatabaseConnectable
14
14
 
15
+ no_commands do
16
+ #
17
+ # @param [String] q
18
+ # @param [Integer] page
19
+ # @param [Integer] limit
20
+ #
21
+ # @return [Mihari::Services::ResultValue]
22
+ #
23
+ def _search(q, page: 1, limit: 10)
24
+ filter = Structs::Filters::Search.new(q: q, page: page, limit: limit)
25
+ Services::RuleSearcher.result(filter).value!
26
+ end
27
+ end
28
+
15
29
  desc "validate [PATH]", "Validate a rule file"
16
30
  #
17
31
  # Validate format of a rule
@@ -44,8 +58,7 @@ module Mihari
44
58
  # @param [String] q
45
59
  #
46
60
  def list(q = "")
47
- filter = Structs::Filters::Search.new(q: q, page: options["page"], limit: options["limit"])
48
- value = Services::RuleSearcher.result(filter).value!
61
+ value = _search(q, page: options["page"], limit: options["limit"])
49
62
  data = Entities::RulesWithPagination.represent(
50
63
  results: value.results,
51
64
  total: value.total,
@@ -55,6 +68,28 @@ module Mihari
55
68
  puts JSON.pretty_generate(data.as_json)
56
69
  end
57
70
 
71
+ desc "list-transform QUERY", "List/search rules with transformation"
72
+ around :with_db_connection
73
+ method_option :template, type: :string, required: true, aliases: "-t",
74
+ description: "Jbuilder template itself or a path to a template file"
75
+ method_option :page, type: :numeric, default: 1
76
+ method_option :limit, type: :numeric, default: 10
77
+ #
78
+ # @param [String] q
79
+ #
80
+ def list_transform(q = "")
81
+ value = _search(q, page: options["page"], limit: options["limit"])
82
+ puts Services::JbuilderRenderer.call(
83
+ options["template"],
84
+ {
85
+ results: value.results,
86
+ total: value.total,
87
+ current_page: value.filter[:page].to_i,
88
+ page_size: value.filter[:limit].to_i
89
+ }
90
+ )
91
+ end
92
+
58
93
  desc "get [ID]", "Get a rule"
59
94
  around :with_db_connection
60
95
  def get(id)
@@ -11,6 +11,20 @@ module Mihari
11
11
  thor.class_eval do
12
12
  include Concerns::DatabaseConnectable
13
13
 
14
+ no_commands do
15
+ #
16
+ # @param [String] q
17
+ # @param [Integer] page
18
+ # @param [Integer] limit
19
+ #
20
+ # @return [Mihari::Services::ResultValue]
21
+ #
22
+ def _search(q, page: 1, limit: 10)
23
+ filter = Structs::Filters::Search.new(q: q, page: page, limit: limit)
24
+ Services::TagSearcher.result(filter).value!
25
+ end
26
+ end
27
+
14
28
  desc "list", "List/search tags"
15
29
  around :with_db_connection
16
30
  method_option :page, type: :numeric, default: 1
@@ -19,8 +33,7 @@ module Mihari
19
33
  # @param [String] q
20
34
  #
21
35
  def list(q = "")
22
- filter = Structs::Filters::Search.new(q: q, page: options["page"], limit: options["limit"])
23
- value = Services::TagSearcher.result(filter).value!
36
+ value = _search(q, page: options["page"], limit: options["limit"])
24
37
  data = Entities::TagsWithPagination.represent(
25
38
  results: value.results,
26
39
  total: value.total,
@@ -30,6 +43,28 @@ module Mihari
30
43
  puts JSON.pretty_generate(data.as_json)
31
44
  end
32
45
 
46
+ desc "list-transform QUERY", "List/search tags with transformation"
47
+ around :with_db_connection
48
+ method_option :template, type: :string, required: true, aliases: "-t",
49
+ description: "Jbuilder template itself or a path to a template file"
50
+ method_option :page, type: :numeric, default: 1
51
+ method_option :limit, type: :numeric, default: 10
52
+ #
53
+ # @param [String] q
54
+ #
55
+ def list_transform(q = "")
56
+ value = _search(q, page: options["page"], limit: options["limit"])
57
+ puts Services::JbuilderRenderer.call(
58
+ options["template"],
59
+ {
60
+ results: value.results,
61
+ total: value.total,
62
+ current_page: value.filter[:page].to_i,
63
+ page_size: value.filter[:limit].to_i
64
+ }
65
+ )
66
+ end
67
+
33
68
  desc "delete [ID]", "Delete a tag"
34
69
  around :with_db_connection
35
70
  #
@@ -17,9 +17,10 @@ module Mihari
17
17
  receiver = err.receiver
18
18
  case receiver
19
19
  when Dry::Monads::Try::Error
20
- receiver.exception
20
+ # Error may be wrapped like Matryoshka
21
+ unwrap_error receiver.exception
21
22
  when Dry::Monads::Failure
22
- receiver.failure
23
+ unwrap_error receiver.failure
23
24
  else
24
25
  err
25
26
  end
@@ -8,35 +8,48 @@ module Mihari
8
8
  module Retriable
9
9
  extend ActiveSupport::Concern
10
10
 
11
- DEFAULT_ON = [
12
- Errno::ECONNRESET,
13
- Errno::ECONNABORTED,
14
- Errno::EPIPE,
11
+ RETRIABLE_ERRORS = [
15
12
  OpenSSL::SSL::SSLError,
16
13
  Timeout::Error,
17
- RetryableError,
18
- NetworkError,
19
- TimeoutError,
20
- StatusCodeError
14
+ ::HTTP::ConnectionError,
15
+ ::HTTP::ResponseError,
16
+ ::HTTP::TimeoutError
21
17
  ].freeze
22
18
 
19
+ DEFAULT_CONDITION = lambda do |error|
20
+ return true if RETRIABLE_ERRORS.any? { |klass| error.is_a? klass }
21
+
22
+ case error
23
+ when StatusError
24
+ error.status_code != 404
25
+ else
26
+ false
27
+ end
28
+ end
29
+
23
30
  #
24
31
  # Retry on error
25
32
  #
26
33
  # @param [Integer] times
27
34
  # @param [Integer] interval
28
35
  # @param [Boolean] exponential_backoff
29
- # @param [Array<StandardError>] on
36
+ # @param [Proc] condition
30
37
  #
31
- def retry_on_error(times: 3, interval: 5, exponential_backoff: true, on: DEFAULT_ON)
38
+ # @param [Object] on
39
+ def retry_on_error(times: 3, interval: 5, exponential_backoff: true, condition: DEFAULT_CONDITION)
32
40
  try = 0
33
41
  begin
34
42
  try += 1
35
43
  yield
36
- rescue *on => e
44
+ rescue StandardError => e
45
+ # Raise error if it's not a retriable error
46
+ raise e unless condition.call(e)
47
+
37
48
  sleep_seconds = exponential_backoff ? interval * (2**(try - 1)) : interval
38
49
  sleep sleep_seconds
39
50
  retry if try < times
51
+
52
+ # Raise error if retry times exceed a given times
40
53
  raise e
41
54
  end
42
55
  end
@@ -28,7 +28,7 @@ module Mihari
28
28
  def ip?
29
29
  Try[IPAddr::InvalidAddressError] do
30
30
  IPAddr.new(data).to_s == data
31
- end.to_result.value_or(false)
31
+ end.recover { false }.value!
32
32
  end
33
33
 
34
34
  # @return [Boolean]
@@ -36,7 +36,7 @@ module Mihari
36
36
  Try[Addressable::URI::InvalidURIError] do
37
37
  uri = Addressable::URI.parse("http://#{data}")
38
38
  uri.host == data && PublicSuffix.valid?(uri.host)
39
- end.to_result.value_or(false)
39
+ end.recover { false }.value!
40
40
  end
41
41
 
42
42
  # @return [Boolean]
@@ -44,7 +44,7 @@ module Mihari
44
44
  Try[Addressable::URI::InvalidURIError] do
45
45
  uri = Addressable::URI.parse(data)
46
46
  uri.scheme && uri.host && uri.path && PublicSuffix.valid?(uri.host)
47
- end.to_result.value_or(false)
47
+ end.recover { false }.value!
48
48
  end
49
49
 
50
50
  # @return [Boolean]
@@ -1,49 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
-
5
3
  module Mihari
6
4
  module Emitters
7
- class ERBTemplate < ERB
8
- class << self
9
- def template
10
- %{
11
- {
12
- "rule": {
13
- "id": "<%= @rule.id %>",
14
- "title": "<%= @rule.title %>",
15
- "description": "<%= @rule.description %>"
16
- },
17
- "artifacts": [
18
- <% @artifacts.each_with_index do |artifact, idx| %>
19
- "<%= artifact.data %>"
20
- <%= ',' if idx < (@artifacts.length - 1) %>
21
- <% end %>
22
- ],
23
- "tags": [
24
- <% @rule.tags.each_with_index do |tag, idx| %>
25
- "<%= tag.name %>"
26
- <%= ',' if idx < (@rule.tags.length - 1) %>
27
- <% end %>
28
- ]
29
- }
30
- }
31
- end
32
- end
33
-
34
- def initialize(artifacts:, rule:, options: {})
35
- @artifacts = artifacts
36
- @rule = rule
37
-
38
- @template = options.fetch(:template, self.class.template)
39
- super(@template)
40
- end
41
-
42
- def result
43
- super(binding)
44
- end
45
- end
46
-
47
5
  class Webhook < Base
48
6
  # @return [Addressable::URI, nil]
49
7
  attr_reader :url
@@ -54,12 +12,24 @@ module Mihari
54
12
  # @return [String]
55
13
  attr_reader :method
56
14
 
57
- # @return [String, nil]
15
+ # @return [String]
58
16
  attr_reader :template
59
17
 
60
18
  # @return [Array<Mihari::Models::Artifact>]
61
19
  attr_accessor :artifacts
62
20
 
21
+ DEFAULT_TEMPLATE = %{
22
+ json.rule do
23
+ json.id rule.id
24
+ json.title rule.title
25
+ json.description rule.description
26
+ end
27
+
28
+ json.artifacts artifacts.map(&:data)
29
+
30
+ json.tags rule.tags.map(&:name)
31
+ }
32
+
63
33
  #
64
34
  # @param [Mihari::Rule] rule
65
35
  # @param [Hash, nil] options
@@ -71,7 +41,7 @@ module Mihari
71
41
  @url = Addressable::URI.parse(params[:url])
72
42
  @headers = params[:headers] || {}
73
43
  @method = params[:method] || "POST"
74
- @template = params[:template]
44
+ @template = params[:template] || DEFAULT_TEMPLATE
75
45
 
76
46
  @artifacts = []
77
47
  end
@@ -114,15 +84,7 @@ module Mihari
114
84
  # @return [String]
115
85
  #
116
86
  def render
117
- options = {}
118
- options[:template] = File.read(template) unless template.nil?
119
-
120
- erb_template = ERBTemplate.new(
121
- artifacts: artifacts,
122
- rule: rule,
123
- options: options
124
- )
125
- erb_template.result
87
+ Services::JbuilderRenderer.call(template, { rule: rule, artifacts: artifacts })
126
88
  end
127
89
 
128
90
  #
data/lib/mihari/errors.rb CHANGED
@@ -1,27 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "http"
4
+
3
5
  module Mihari
4
6
  class Error < StandardError; end
5
7
 
6
8
  class ValueError < Error; end
7
9
 
8
- class RetryableError < Error; end
9
-
10
10
  class ConfigurationError < Error; end
11
11
 
12
- class IntegrityError < Error; end
12
+ class ResponseError < Error; end
13
13
 
14
- # errors for HTTP interactions
15
- class HTTPError < Error; end
14
+ class AnalyzerError < Error
15
+ # @return [StandardException, nil]
16
+ attr_reader :cause
16
17
 
17
- class NetworkError < HTTPError; end
18
+ #
19
+ # @param [String] msg
20
+ # @param [String] analyzer
21
+ # @param [StandardException, nil] cause
22
+ #
23
+ def initialize(msg, analyzer, cause: nil)
24
+ super("#{msg} (from #{analyzer})")
25
+
26
+ @cause = cause
27
+ set_backtrace(cause.backtrace) if cause
28
+ end
18
29
 
19
- class TimeoutError < HTTPError; end
30
+ def detail
31
+ return nil unless cause.respond_to?(:detail)
32
+
33
+ cause.detail
34
+ end
35
+ end
36
+
37
+ class IntegrityError < Error; end
20
38
 
21
39
  #
22
- # HTTP status code error
40
+ # HTTP status error
23
41
  #
24
- class StatusCodeError < HTTPError
42
+ class StatusError < ::HTTP::Error
25
43
  # @return [Integer]
26
44
  attr_reader :status_code
27
45
 
@@ -39,6 +57,10 @@ module Mihari
39
57
  @status_code = status_code
40
58
  @body = body
41
59
  end
60
+
61
+ def detail
62
+ { status_code: status_code, body: body }
63
+ end
42
64
  end
43
65
 
44
66
  #
@@ -56,5 +78,9 @@ module Mihari
56
78
 
57
79
  @errors = errors
58
80
  end
81
+
82
+ def detail
83
+ errors.to_h
84
+ end
59
85
  end
60
86
  end
data/lib/mihari/http.rb CHANGED
@@ -11,18 +11,13 @@ module Mihari
11
11
  def wrap_response(response)
12
12
  return response if response.status.success?
13
13
 
14
- raise StatusCodeError.new(
14
+ raise StatusError.new(
15
15
  "Unsuccessful response code returned: #{response.code}",
16
16
  response.code,
17
17
  response.body.to_s
18
18
  )
19
19
  end
20
20
 
21
- def on_error(_request, error)
22
- raise TimeoutError, error if error.is_a?(::HTTP::TimeoutError)
23
- raise NetworkError, error if error.is_a?(::HTTP::Error)
24
- end
25
-
26
21
  ::HTTP::Options.register_feature(:better_error, self)
27
22
  end
28
23
 
data/lib/mihari/rule.rb CHANGED
@@ -83,6 +83,20 @@ module Mihari
83
83
  data[:data_types]
84
84
  end
85
85
 
86
+ #
87
+ # @return [Date, nil]
88
+ #
89
+ def created_on
90
+ data[:created_on]
91
+ end
92
+
93
+ #
94
+ # @return [Date, nil]
95
+ #
96
+ def updated_on
97
+ data[:updated_on]
98
+ end
99
+
86
100
  #
87
101
  # @return [Array<Mihari::Models::Tag>]
88
102
  #
@@ -279,8 +293,8 @@ module Mihari
279
293
  # data is serialized as JSON so dates (created_on & updated_on) are stringified in there
280
294
  # thus dates & (hash) keys have to be stringified when comparing
281
295
  data.deep_dup.tap do |data|
282
- data[:created_on] = data[:created_on].to_s
283
- data[:updated_on] = data[:updated_on].to_s
296
+ data[:created_on] = created_on.to_s unless created_on.nil?
297
+ data[:updated_on] = updated_on.to_s unless updated_on.nil?
284
298
  end.deep_stringify_keys
285
299
  end
286
300
 
@@ -0,0 +1,31 @@
1
+ require "tilt/jbuilder"
2
+
3
+ module Mihari
4
+ module Services
5
+ #
6
+ # Jbuilder based JSON renderer
7
+ #
8
+ class JbuilderRenderer < Service
9
+ attr_reader :template
10
+
11
+ #
12
+ # @param [String] template
13
+ # @param [Hash] params
14
+ #
15
+ # @return [String]
16
+ #
17
+ def call(template, params = {})
18
+ @template = template
19
+
20
+ jbuilder_template = Tilt::JbuilderTemplate.new { template_string }
21
+ jbuilder_template.render(nil, params)
22
+ end
23
+
24
+ def template_string
25
+ return File.read(template) if Pathname(template).exist?
26
+
27
+ template
28
+ end
29
+ end
30
+ end
31
+ end