mihari 7.0.4 → 7.1.0

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