mihari 3.6.0 → 3.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.gitmodules +3 -0
  3. data/README.md +2 -0
  4. data/Steepfile +32 -0
  5. data/lib/mihari/analyzers/base.rb +5 -5
  6. data/lib/mihari/analyzers/binaryedge.rb +13 -0
  7. data/lib/mihari/analyzers/censys.rb +5 -0
  8. data/lib/mihari/analyzers/circl.rb +15 -0
  9. data/lib/mihari/analyzers/crtsh.rb +5 -0
  10. data/lib/mihari/analyzers/dnpedia.rb +5 -0
  11. data/lib/mihari/analyzers/dnstwister.rb +17 -0
  12. data/lib/mihari/analyzers/onyphe.rb +20 -4
  13. data/lib/mihari/analyzers/otx.rb +20 -0
  14. data/lib/mihari/analyzers/passivetotal.rb +25 -0
  15. data/lib/mihari/analyzers/pulsedive.rb +10 -0
  16. data/lib/mihari/analyzers/rule.rb +18 -0
  17. data/lib/mihari/analyzers/securitytrails.rb +25 -0
  18. data/lib/mihari/analyzers/shodan.rb +13 -0
  19. data/lib/mihari/analyzers/spyse.rb +20 -0
  20. data/lib/mihari/analyzers/urlscan.rb +10 -0
  21. data/lib/mihari/analyzers/virustotal.rb +20 -0
  22. data/lib/mihari/analyzers/zoomeye.rb +38 -0
  23. data/lib/mihari/emitters/base.rb +1 -1
  24. data/lib/mihari/emitters/misp.rb +38 -5
  25. data/lib/mihari/emitters/slack.rb +20 -2
  26. data/lib/mihari/emitters/the_hive.rb +16 -3
  27. data/lib/mihari/emitters/webhook.rb +18 -3
  28. data/lib/mihari/mixins/disallowed_data_value.rb +1 -1
  29. data/lib/mihari/structs/onyphe.rb +2 -2
  30. data/lib/mihari/type_checker.rb +9 -9
  31. data/lib/mihari/version.rb +1 -1
  32. data/mihari.gemspec +1 -0
  33. data/sig/lib/mihari/analyzers/base.rbs +99 -0
  34. data/sig/lib/mihari/analyzers/basic.rbs +17 -0
  35. data/sig/lib/mihari/analyzers/binaryedge.rbs +25 -0
  36. data/sig/lib/mihari/analyzers/censys.rbs +38 -0
  37. data/sig/lib/mihari/analyzers/circl.rbs +29 -0
  38. data/sig/lib/mihari/analyzers/crtsh.rbs +19 -0
  39. data/sig/lib/mihari/analyzers/dnpedia.rbs +18 -0
  40. data/sig/lib/mihari/analyzers/dnstwister.rbs +27 -0
  41. data/sig/lib/mihari/analyzers/onyphe.rbs +33 -0
  42. data/sig/lib/mihari/analyzers/otx.rbs +33 -0
  43. data/sig/lib/mihari/analyzers/passivetotal.rbs +33 -0
  44. data/sig/lib/mihari/analyzers/pulsedive.rbs +27 -0
  45. data/sig/lib/mihari/analyzers/rule.rbs +68 -0
  46. data/sig/lib/mihari/analyzers/securitytrails.rbs +33 -0
  47. data/sig/lib/mihari/analyzers/shodan.rbs +33 -0
  48. data/sig/lib/mihari/analyzers/spyse.rbs +29 -0
  49. data/sig/lib/mihari/analyzers/urlscan.rbs +28 -0
  50. data/sig/lib/mihari/analyzers/virustotal.rbs +31 -0
  51. data/sig/lib/mihari/analyzers/zoomeye.rbs +33 -0
  52. data/sig/lib/mihari/cli/analyzer.rbs +39 -0
  53. data/sig/lib/mihari/cli/base.rbs +11 -0
  54. data/sig/lib/mihari/cli/init.rbs +7 -0
  55. data/sig/lib/mihari/cli/main.rbs +9 -0
  56. data/sig/lib/mihari/cli/mixins/utils.rbs +50 -0
  57. data/sig/lib/mihari/cli/validator.rbs +7 -0
  58. data/sig/lib/mihari/commands/binaryedge.rbs +7 -0
  59. data/sig/lib/mihari/commands/censys.rbs +7 -0
  60. data/sig/lib/mihari/commands/circl.rbs +7 -0
  61. data/sig/lib/mihari/commands/crtsh.rbs +7 -0
  62. data/sig/lib/mihari/commands/dnpedia.rbs +7 -0
  63. data/sig/lib/mihari/commands/dnstwister.rbs +7 -0
  64. data/sig/lib/mihari/commands/init.rbs +11 -0
  65. data/sig/lib/mihari/commands/json.rbs +7 -0
  66. data/sig/lib/mihari/commands/onyphe.rbs +7 -0
  67. data/sig/lib/mihari/commands/otx.rbs +7 -0
  68. data/sig/lib/mihari/commands/passivetotal.rbs +7 -0
  69. data/sig/lib/mihari/commands/pulsedive.rbs +7 -0
  70. data/sig/lib/mihari/commands/search.rbs +35 -0
  71. data/sig/lib/mihari/commands/securitytrails.rbs +7 -0
  72. data/sig/lib/mihari/commands/shodan.rbs +7 -0
  73. data/sig/lib/mihari/commands/spyse.rbs +7 -0
  74. data/sig/lib/mihari/commands/urlscan.rbs +7 -0
  75. data/sig/lib/mihari/commands/validator.rbs +11 -0
  76. data/sig/lib/mihari/commands/virustotal.rbs +7 -0
  77. data/sig/lib/mihari/commands/web.rbs +7 -0
  78. data/sig/lib/mihari/commands/zoomeye.rbs +7 -0
  79. data/sig/lib/mihari/constants.rbs +3 -0
  80. data/sig/lib/mihari/database.rbs +25 -0
  81. data/sig/lib/mihari/emitters/base.rbs +18 -0
  82. data/sig/lib/mihari/emitters/database.rbs +9 -0
  83. data/sig/lib/mihari/emitters/misp.rbs +28 -0
  84. data/sig/lib/mihari/emitters/slack.rbs +58 -0
  85. data/sig/lib/mihari/emitters/stdout.rbs +9 -0
  86. data/sig/lib/mihari/emitters/the_hive.rbs +24 -0
  87. data/sig/lib/mihari/emitters/webhook.rbs +20 -0
  88. data/sig/lib/mihari/errors.rbs +10 -0
  89. data/sig/lib/mihari/mixins/configurable.rbs +26 -0
  90. data/sig/lib/mihari/mixins/configuration.rbs +45 -0
  91. data/sig/lib/mihari/mixins/disallowed_data_value.rbs +25 -0
  92. data/sig/lib/mihari/mixins/hash.rbs +14 -0
  93. data/sig/lib/mihari/mixins/refang.rbs +14 -0
  94. data/sig/lib/mihari/mixins/retriable.rbs +15 -0
  95. data/sig/lib/mihari/mixins/rule.rbs +41 -0
  96. data/sig/lib/mihari/models/alert.rbs +46 -0
  97. data/sig/lib/mihari/models/artifact.rbs +54 -0
  98. data/sig/lib/mihari/models/autonomous_system.rbs +5 -0
  99. data/sig/lib/mihari/models/dns.rbs +19 -0
  100. data/sig/lib/mihari/models/geolocation.rbs +6 -0
  101. data/sig/lib/mihari/models/reverse_dns.rbs +14 -0
  102. data/sig/lib/mihari/models/tag.rbs +5 -0
  103. data/sig/lib/mihari/models/tagging.rbs +4 -0
  104. data/sig/lib/mihari/models/whois.rbs +66 -0
  105. data/sig/lib/mihari/notifiers/base.rbs +18 -0
  106. data/sig/lib/mihari/notifiers/exception_notifier.rbs +75 -0
  107. data/sig/lib/mihari/notifiers/slack.rbs +50 -0
  108. data/sig/lib/mihari/status.rbs +25 -0
  109. data/sig/lib/mihari/structs/censys.rbs +50 -0
  110. data/sig/lib/mihari/structs/onyphe.rbs +25 -0
  111. data/sig/lib/mihari/structs/shodan.rbs +28 -0
  112. data/sig/lib/mihari/type_checker.rbs +48 -0
  113. data/sig/lib/mihari/types.rbs +17 -0
  114. data/sig/lib/mihari/version.rbs +3 -0
  115. data/sig/lib/mihari/web/app.rbs +5 -0
  116. data/sig/lib/mihari.rbs +57 -0
  117. metadata +102 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9c1cbdf0570c25e2d89d7f6fd402b64991dfaebc75cf3cf5422a56504287ae9
4
- data.tar.gz: d5b7a0db7b49f245e3c949135011fb674e28a9d3c251e165f827e8f3d90673b1
3
+ metadata.gz: 14d0c74e85fbf6ef624afefe7e948595586d6c00fa8bc32f211e60caee581fc3
4
+ data.tar.gz: 9d5fde6d69f664efac0d6c56e6a0ba60adcad0edcfe45c69f285ffcaba8d11f0
5
5
  SHA512:
6
- metadata.gz: e76a216dedbc1aec17748c37a1b874c2c825fed6f7716ef356a48ddf2861584da299c384737e588a48b67165874f495192bb42a4c20c2f29f4620f8b559d1a83
7
- data.tar.gz: 2f3e380b252ba238594ccacd2df8e362a62b9685aafeff34d6506e7301184cf5b38c4244db9498842f4aee9df109d7c43a9ad99cecc3b63616d333a7f5093333
6
+ metadata.gz: 30597743e91f124388fbdf426199d97a00f92db9e525fe5725643d529d319ca670d1e9aa6eccff86c0844802157ca3d5c7db9b9afd925d6f0ac4d8b881c44949
7
+ data.tar.gz: 9d199a3c2f6c7794214730de7c8db812e939c5ddd11ab06d1c6f28c3b8764b2881958b0684adb59e8516d0ac4b6a1dc66b19c5a593efaac9695cb6e317ce8105
data/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "vendor/rbs/gem_rbs_collection"]
2
+ path = vendor/rbs/gem_rbs_collection
3
+ url = https://github.com/ruby/gem_rbs_collection.git
data/README.md CHANGED
@@ -64,3 +64,5 @@ The gem is available as open source under the terms of the [MIT License](https:/
64
64
  ## Acknowledgement
65
65
 
66
66
  Mihari is proudly supported by [Tines.io](https://tines.io?utm_source=github&utm_medium=sponsorship&utm_campaign=ninoseki), The SOAR Platform for Enterprise Security Teams.
67
+
68
+ $ bundle exec rbs -rpathname --repo=gem_rbs/gems -ractivesupport -ractionpack -ractivejob -ractivemodel -ractionview -ractiverecord -rrailties -I sig validate
data/Steepfile ADDED
@@ -0,0 +1,32 @@
1
+ target :lib do
2
+ signature "sig"
3
+ check "lib"
4
+
5
+ repo_path "vendor/rbs/gem_rbs_collection/gems"
6
+
7
+ library "date"
8
+ library "json"
9
+ library "logger"
10
+ library "monitor"
11
+ library "mutex_m"
12
+ library "pathname"
13
+ library "securerandom"
14
+ library "singleton"
15
+ library "time"
16
+ library "tsort"
17
+ library "uri"
18
+ library "resolv"
19
+ library "timeout"
20
+ library "socket"
21
+
22
+ library "rack"
23
+
24
+ library "actionpack"
25
+ library "actionview"
26
+ library "activejob"
27
+ library "activemodel"
28
+ library "activerecord"
29
+ library "activesupport"
30
+ library "parallel"
31
+ library "railties"
32
+ end
@@ -27,7 +27,7 @@ module Mihari
27
27
 
28
28
  # @return [String]
29
29
  def title
30
- self.class.to_s.split("::").last
30
+ self.class.to_s.split("::").last.to_s
31
31
  end
32
32
 
33
33
  # @return [String]
@@ -37,7 +37,7 @@ module Mihari
37
37
 
38
38
  # @return [String]
39
39
  def source
40
- self.class.to_s.split("::").last
40
+ self.class.to_s.split("::").last.to_s
41
41
  end
42
42
 
43
43
  # @return [Array<String>]
@@ -125,9 +125,9 @@ module Mihari
125
125
  #
126
126
  def set_enriched_artifacts
127
127
  retry_on_error { enriched_artifacts }
128
- rescue ArgumentError => _e
128
+ rescue ArgumentError => e
129
129
  klass = self.class.to_s.split("::").last.to_s
130
- raise Error, "Please configure #{klass} API settings properly"
130
+ raise Error, "Please configure #{klass} settings properly. (#{e})"
131
131
  end
132
132
 
133
133
  #
@@ -139,7 +139,7 @@ module Mihari
139
139
  @valid_emitters ||= Mihari.emitters.filter_map do |klass|
140
140
  emitter = klass.new
141
141
  emitter.valid? ? emitter : nil
142
- end
142
+ end.compact
143
143
  end
144
144
 
145
145
  #
@@ -26,6 +26,14 @@ module Mihari
26
26
 
27
27
  PAGE_SIZE = 20
28
28
 
29
+ #
30
+ # Search with pagination
31
+ #
32
+ # @param [String] query
33
+ # @param [Integer] page
34
+ #
35
+ # @return [Hash]
36
+ #
29
37
  def search_with_page(query, page: 1)
30
38
  api.host.search(query, page: page)
31
39
  rescue ::BinaryEdge::Error => e
@@ -34,6 +42,11 @@ module Mihari
34
42
  raise e
35
43
  end
36
44
 
45
+ #
46
+ # Search
47
+ #
48
+ # @return [Array<Hash>]
49
+ #
37
50
  def search
38
51
  responses = []
39
52
  (1..Float::INFINITY).each do |page|
@@ -16,6 +16,11 @@ module Mihari
16
16
 
17
17
  private
18
18
 
19
+ #
20
+ # Search
21
+ #
22
+ # @return [Array<String>]
23
+ #
19
24
  def search
20
25
  artifacts = []
21
26
 
@@ -35,6 +35,11 @@ module Mihari
35
35
  @api ||= ::PassiveCIRCL::API.new(username: Mihari.config.circl_passive_username, password: Mihari.config.circl_passive_password)
36
36
  end
37
37
 
38
+ #
39
+ # Passive DNS/SSL search
40
+ #
41
+ # @return [Array<String>]
42
+ #
38
43
  def search
39
44
  case @type
40
45
  when "domain"
@@ -46,6 +51,11 @@ module Mihari
46
51
  end
47
52
  end
48
53
 
54
+ #
55
+ # Passive DNS search
56
+ #
57
+ # @return [Array<String>]
58
+ #
49
59
  def passive_dns_search
50
60
  results = api.dns.query(@query)
51
61
  results.filter_map do |result|
@@ -54,6 +64,11 @@ module Mihari
54
64
  end.uniq
55
65
  end
56
66
 
67
+ #
68
+ # Passive SSL search
69
+ #
70
+ # @return [Array<String>]
71
+ #
57
72
  def passive_ssl_search
58
73
  result = api.ssl.cquery(@query)
59
74
  seen = result["seen"] || []
@@ -23,6 +23,11 @@ module Mihari
23
23
  @api ||= ::Crtsh::API.new
24
24
  end
25
25
 
26
+ #
27
+ # Search
28
+ #
29
+ # @return [Array<Hash>]
30
+ #
26
31
  def search
27
32
  exclude = exclude_expired ? "expired" : nil
28
33
  api.search(query, exclude: exclude)
@@ -20,6 +20,11 @@ module Mihari
20
20
  @api ||= ::DNPedia::API.new
21
21
  end
22
22
 
23
+ #
24
+ # Search
25
+ #
26
+ # @return [Array<String>]
27
+ #
23
28
  def search
24
29
  res = api.search(query)
25
30
  rows = res["rows"] || []
@@ -29,6 +29,11 @@ module Mihari
29
29
 
30
30
  private
31
31
 
32
+ #
33
+ # Check whether a type is valid or not
34
+ #
35
+ # @return [Boolean]
36
+ #
32
37
  def valid_type?
33
38
  type == "domain"
34
39
  end
@@ -37,6 +42,13 @@ module Mihari
37
42
  @api ||= ::DNSTwister::API.new
38
43
  end
39
44
 
45
+ #
46
+ # Check whether a domain is resolvable or not
47
+ #
48
+ # @param [String] domain
49
+ #
50
+ # @return [Boolean]
51
+ #
40
52
  def resolvable?(domain)
41
53
  Resolv.getaddress domain
42
54
  true
@@ -44,6 +56,11 @@ module Mihari
44
56
  false
45
57
  end
46
58
 
59
+ #
60
+ # Search
61
+ #
62
+ # @return [Array<String>]
63
+ #
47
64
  def search
48
65
  raise InvalidInputError, "#{query}(type: #{type || "unknown"}) is not supported." unless valid_type?
49
66
 
@@ -33,11 +33,24 @@ module Mihari
33
33
  @api ||= ::Onyphe::API.new(Mihari.config.onyphe_api_key)
34
34
  end
35
35
 
36
+ #
37
+ # Search with pagination
38
+ #
39
+ # @param [String] query
40
+ # @param [Integer] page
41
+ #
42
+ # @return [Structs::Onyphe::Response]
43
+ #
36
44
  def search_with_page(query, page: 1)
37
45
  res = api.simple.datascan(query, page: page)
38
46
  Structs::Onyphe::Response.from_dynamic!(res)
39
47
  end
40
48
 
49
+ #
50
+ # Search
51
+ #
52
+ # @return [Array<Structs::Onyphe::Response>]
53
+ #
41
54
  def search
42
55
  responses = []
43
56
  (1..Float::INFINITY).each do |page|
@@ -60,10 +73,13 @@ module Mihari
60
73
  def build_artifact(result)
61
74
  as = AutonomousSystem.new(asn: normalize_asn(result.asn))
62
75
 
63
- geolocation = Geolocation.new(
64
- country: NormalizeCountry(result.country_code, to: :short),
65
- country_code: result.country_code
66
- )
76
+ geolocation = nil
77
+ unless result.country_code.nil?
78
+ geolocation = Geolocation.new(
79
+ country: NormalizeCountry(result.country_code, to: :short),
80
+ country_code: result.country_code
81
+ )
82
+ end
67
83
 
68
84
  Artifact.new(
69
85
  data: result.ip,
@@ -39,10 +39,20 @@ module Mihari
39
39
  @ip_client ||= ::OTX::IP.new(Mihari.config.otx_api_key)
40
40
  end
41
41
 
42
+ #
43
+ # Check whether a type is valid or not
44
+ #
45
+ # @return [Boolean]
46
+ #
42
47
  def valid_type?
43
48
  %w[ip domain].include? type
44
49
  end
45
50
 
51
+ #
52
+ # IP/domain search
53
+ #
54
+ # @return [Array<String>]
55
+ #
46
56
  def search
47
57
  case type
48
58
  when "domain"
@@ -54,6 +64,11 @@ module Mihari
54
64
  end
55
65
  end
56
66
 
67
+ #
68
+ # Domain search
69
+ #
70
+ # @return [Array<String>]
71
+ #
57
72
  def domain_search
58
73
  records = domain_client.get_passive_dns(query)
59
74
  records.filter_map do |record|
@@ -61,6 +76,11 @@ module Mihari
61
76
  end.uniq
62
77
  end
63
78
 
79
+ #
80
+ # IP search
81
+ #
82
+ # @return [Array<String>]
83
+ #
64
84
  def ip_search
65
85
  records = ip_client.get_passive_dns(query)
66
86
  records.filter_map do |record|
@@ -35,10 +35,20 @@ module Mihari
35
35
  @api ||= ::PassiveTotal::API.new(username: Mihari.config.passivetotal_username, api_key: Mihari.config.passivetotal_api_key)
36
36
  end
37
37
 
38
+ #
39
+ # Check whether a type is valid or not
40
+ #
41
+ # @return [Boolean]
42
+ #
38
43
  def valid_type?
39
44
  %w[ip domain mail hash].include? type
40
45
  end
41
46
 
47
+ #
48
+ # Passive DNS/SSL, reverse whois search
49
+ #
50
+ # @return [Array<String>]
51
+ #
42
52
  def search
43
53
  case type
44
54
  when "domain", "ip"
@@ -52,11 +62,21 @@ module Mihari
52
62
  end
53
63
  end
54
64
 
65
+ #
66
+ # Passive DNS search
67
+ #
68
+ # @return [Array<String>]
69
+ #
55
70
  def passive_dns_search
56
71
  res = api.dns.passive_unique(query)
57
72
  res["results"] || []
58
73
  end
59
74
 
75
+ #
76
+ # Reverse whois search
77
+ #
78
+ # @return [Array<String>]
79
+ #
60
80
  def reverse_whois_search
61
81
  res = api.whois.search(query: query, field: "email")
62
82
  results = res["results"] || []
@@ -65,6 +85,11 @@ module Mihari
65
85
  end.flatten.compact.uniq
66
86
  end
67
87
 
88
+ #
89
+ # Passive SSL search
90
+ #
91
+ # @return [Array<String>]
92
+ #
68
93
  def ssl_search
69
94
  res = api.ssl.history(query)
70
95
  results = res["results"] || []
@@ -35,10 +35,20 @@ module Mihari
35
35
  @api ||= ::Pulsedive::API.new(Mihari.config.pulsedive_api_key)
36
36
  end
37
37
 
38
+ #
39
+ # Check whether a type is valid or not
40
+ #
41
+ # @return [Boolean]
42
+ #
38
43
  def valid_type?
39
44
  %w[ip domain].include? type
40
45
  end
41
46
 
47
+ #
48
+ # Search
49
+ #
50
+ # @return [Array<String>]
51
+ #
42
52
  def search
43
53
  raise InvalidInputError, "#{query}(type: #{type || "unknown"}) is not supported." unless valid_type?
44
54
 
@@ -22,6 +22,8 @@ module Mihari
22
22
  super(**kwargs)
23
23
 
24
24
  @source = id || UUIDTools::UUID.md5_create(UUIDTools::UUID_URL_NAMESPACE, title + description).to_s
25
+
26
+ validate_analyzer_configurations
25
27
  end
26
28
 
27
29
  ANALYZER_TO_CLASS = {
@@ -119,6 +121,22 @@ module Mihari
119
121
 
120
122
  raise ArgumentError, "#{analyzer_name} is not supported"
121
123
  end
124
+
125
+ #
126
+ # Validate configuration of analyzers
127
+ #
128
+ def validate_analyzer_configurations
129
+ queries.each do |params|
130
+ analyzer_name = params[:analyzer]
131
+ klass = get_analyzer_class(analyzer_name)
132
+
133
+ instance = klass.new("dummy")
134
+ unless instance.configured?
135
+ klass_name = klass.to_s.split("::").last
136
+ raise ArgumentError, "#{klass_name} is not configured correctly"
137
+ end
138
+ end
139
+ end
122
140
  end
123
141
  end
124
142
  end
@@ -35,10 +35,20 @@ module Mihari
35
35
  @api ||= ::SecurityTrails::API.new(Mihari.config.securitytrails_api_key)
36
36
  end
37
37
 
38
+ #
39
+ # Check whether a type is valid or not
40
+ #
41
+ # @return [Boolean]
42
+ #
38
43
  def valid_type?
39
44
  %w[ip domain mail].include? type
40
45
  end
41
46
 
47
+ #
48
+ # IP/domain/mail search
49
+ #
50
+ # @return [Array<String>]
51
+ #
42
52
  def search
43
53
  case type
44
54
  when "domain"
@@ -52,6 +62,11 @@ module Mihari
52
62
  end
53
63
  end
54
64
 
65
+ #
66
+ # Domain search
67
+ #
68
+ # @return [Array<String>]
69
+ #
55
70
  def domain_search
56
71
  result = api.history.get_all_dns_history(query, type: "a")
57
72
  records = result["records"] || []
@@ -60,12 +75,22 @@ module Mihari
60
75
  end.flatten.compact.uniq
61
76
  end
62
77
 
78
+ #
79
+ # IP search
80
+ #
81
+ # @return [Array<String>]
82
+ #
63
83
  def ip_search
64
84
  result = api.domains.search(filter: { ipv4: query })
65
85
  records = result["records"] || []
66
86
  records.filter_map { |record| record["hostname"] }.uniq
67
87
  end
68
88
 
89
+ #
90
+ # Mail search
91
+ #
92
+ # @return [Array<String>]
93
+ #
69
94
  def mail_search
70
95
  result = api.domains.search(filter: { whois_email: query })
71
96
  records = result["records"] || []