mihari 3.5.0 → 3.6.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/config.ru +1 -0
  3. data/lib/mihari/analyzers/base.rb +34 -6
  4. data/lib/mihari/analyzers/censys.rb +37 -9
  5. data/lib/mihari/analyzers/onyphe.rb +34 -9
  6. data/lib/mihari/analyzers/shodan.rb +26 -5
  7. data/lib/mihari/{constraints.rb → constants.rb} +0 -0
  8. data/lib/mihari/database.rb +42 -3
  9. data/lib/mihari/models/alert.rb +8 -4
  10. data/lib/mihari/models/artifact.rb +55 -0
  11. data/lib/mihari/models/autonomous_system.rb +9 -0
  12. data/lib/mihari/models/dns.rb +53 -0
  13. data/lib/mihari/models/geolocation.rb +9 -0
  14. data/lib/mihari/models/reverse_dns.rb +24 -0
  15. data/lib/mihari/models/whois.rb +119 -0
  16. data/lib/mihari/schemas/rule.rb +2 -15
  17. data/lib/mihari/serializers/alert.rb +6 -4
  18. data/lib/mihari/serializers/artifact.rb +11 -2
  19. data/lib/mihari/serializers/autonomous_system.rb +9 -0
  20. data/lib/mihari/serializers/dns.rb +11 -0
  21. data/lib/mihari/serializers/geolocation.rb +11 -0
  22. data/lib/mihari/serializers/reverse_dns.rb +11 -0
  23. data/lib/mihari/serializers/tag.rb +4 -2
  24. data/lib/mihari/serializers/whois.rb +11 -0
  25. data/lib/mihari/structs/censys.rb +92 -0
  26. data/lib/mihari/structs/onyphe.rb +47 -0
  27. data/lib/mihari/structs/shodan.rb +53 -0
  28. data/lib/mihari/types.rb +21 -0
  29. data/lib/mihari/version.rb +1 -1
  30. data/lib/mihari/web/controllers/artifacts_controller.rb +26 -7
  31. data/lib/mihari/web/controllers/sources_controller.rb +2 -2
  32. data/lib/mihari/web/public/index.html +1 -1
  33. data/lib/mihari/web/public/redoc-static.html +2 -2
  34. data/lib/mihari/web/public/static/js/app.8e3e5150.js +36 -0
  35. data/lib/mihari/web/public/static/js/app.8e3e5150.js.map +1 -0
  36. data/lib/mihari.rb +24 -4
  37. data/mihari.gemspec +7 -1
  38. metadata +106 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2fe0203f89908abbc21df8d595b7167a561c826b933166e531298b83f67e085
4
- data.tar.gz: 03a455ebd71f5d3c228041351b6ee8a6a2d89b74a7fc2f0d7609293bc82da7d6
3
+ metadata.gz: c9c1cbdf0570c25e2d89d7f6fd402b64991dfaebc75cf3cf5422a56504287ae9
4
+ data.tar.gz: d5b7a0db7b49f245e3c949135011fb674e28a9d3c251e165f827e8f3d90673b1
5
5
  SHA512:
6
- metadata.gz: aa4a28142eb109d46109d960c1de935ac4632136bc7696b405bfda3d691de2658764e7f796a8e9b1715973e0fc3cc9887997f3ab0dd054e4caa6baf6da40cb8e
7
- data.tar.gz: 057c5df7efd59ebf285b4a8d6a17a8857d8fa5df7b0c61f4ddbaf67d3688aaa4c34187af88acfe151a27a21b1d33b5e659347968ba6881e98e3b81114d113eb9
6
+ metadata.gz: e76a216dedbc1aec17748c37a1b874c2c825fed6f7716ef356a48ddf2861584da299c384737e588a48b67165874f495192bb42a4c20c2f29f4620f8b559d1a83
7
+ data.tar.gz: 2f3e380b252ba238594ccacd2df8e362a62b9685aafeff34d6506e7301184cf5b38c4244db9498842f4aee9df109d7c43a9ad99cecc3b63616d333a7f5093333
data/config.ru CHANGED
@@ -1,3 +1,4 @@
1
+ # bundle exec rerun -- rackup config.ru
1
2
  require "./lib/mihari"
2
3
 
3
4
  # set rack env as development
@@ -51,7 +51,7 @@ module Mihari
51
51
  # @return [nil]
52
52
  #
53
53
  def run
54
- set_unique_artifacts
54
+ set_enriched_artifacts
55
55
 
56
56
  Parallel.each(valid_emitters) do |emitter|
57
57
  run_emitter emitter
@@ -66,7 +66,7 @@ module Mihari
66
66
  # @return [nil]
67
67
  #
68
68
  def run_emitter(emitter)
69
- emitter.run(title: title, description: description, artifacts: unique_artifacts, source: source, tags: tags)
69
+ emitter.run(title: title, description: description, artifacts: enriched_artifacts, source: source, tags: tags)
70
70
  rescue StandardError => e
71
71
  puts "Emission by #{emitter.class} is failed: #{e}"
72
72
  end
@@ -88,7 +88,7 @@ module Mihari
88
88
  # No need to set data_type manually
89
89
  # It is set automatically in #initialize
90
90
  artifact.is_a?(Artifact) ? artifact : Artifact.new(data: artifact, source: source)
91
- end.select(&:valid?)
91
+ end.select(&:valid?).uniq(&:data)
92
92
  end
93
93
 
94
94
  private
@@ -105,12 +105,26 @@ module Mihari
105
105
  end
106
106
 
107
107
  #
108
- # Set unique artifacts
108
+ # Enriched artifacts
109
+ #
110
+ # @return [Array<Mihari::Artifact>]
111
+ #
112
+ def enriched_artifacts
113
+ @enriched_artifacts ||= unique_artifacts.map do |artifact|
114
+ artifact.enrich_whois
115
+ artifact.enrich_dns
116
+ artifact.enrich_reverse_dns
117
+ artifact
118
+ end
119
+ end
120
+
121
+ #
122
+ # Set enriched artifacts
109
123
  #
110
124
  # @return [nil]
111
125
  #
112
- def set_unique_artifacts
113
- retry_on_error { unique_artifacts }
126
+ def set_enriched_artifacts
127
+ retry_on_error { enriched_artifacts }
114
128
  rescue ArgumentError => _e
115
129
  klass = self.class.to_s.split("::").last.to_s
116
130
  raise Error, "Please configure #{klass} API settings properly"
@@ -127,6 +141,20 @@ module Mihari
127
141
  emitter.valid? ? emitter : nil
128
142
  end
129
143
  end
144
+
145
+ #
146
+ # Normalize ASN value
147
+ #
148
+ # @param [String, Integer] asn
149
+ #
150
+ # @return [Integer]
151
+ #
152
+ def normalize_asn(asn)
153
+ return asn if asn.is_a?(Integer)
154
+ return asn.to_i unless asn.start_with?("AS")
155
+
156
+ asn.delete_prefix("AS").to_i
157
+ end
130
158
  end
131
159
  end
132
160
  end
@@ -17,31 +17,59 @@ module Mihari
17
17
  private
18
18
 
19
19
  def search
20
- ipv4s = []
20
+ artifacts = []
21
21
 
22
22
  cursor = nil
23
23
  loop do
24
24
  response = api.search(query, cursor: cursor)
25
- ipv4s << response_to_ipv4s(response)
25
+ response = Structs::Censys::Response.from_dynamic!(response)
26
26
 
27
- links = response.dig("result", "links")
28
- cursor = links["next"]
27
+ artifacts << response_to_artifacts(response)
28
+
29
+ cursor = response.result.links.next
29
30
  break if cursor == ""
30
31
  end
31
32
 
32
- ipv4s.flatten
33
+ artifacts.flatten.uniq(&:data)
33
34
  end
34
35
 
35
36
  #
36
37
  # Extract IPv4s from Censys search API response
37
38
  #
38
- # @param [Hash] response
39
+ # @param [Structs::Censys::Response] response
39
40
  #
40
41
  # @return [Array<String>]
41
42
  #
42
- def response_to_ipv4s(response)
43
- hits = response.dig("result", "hits") || []
44
- hits.map { |hit| hit["ip"] }
43
+ def response_to_artifacts(response)
44
+ response.result.hits.map { |hit| build_artifact(hit) }
45
+ end
46
+
47
+ #
48
+ # Build an artifact from a Shodan search API response
49
+ #
50
+ # @param [Structs::Censys::Hit] hit
51
+ #
52
+ # @return [Artifact]
53
+ #
54
+ def build_artifact(hit)
55
+ as = AutonomousSystem.new(asn: normalize_asn(hit.autonomous_system.asn))
56
+
57
+ # sometimes Censys overlooks country
58
+ # then set geolocation as nil
59
+ geolocation = nil
60
+ unless hit.location.country.nil?
61
+ geolocation = Geolocation.new(
62
+ country: hit.location.country,
63
+ country_code: hit.location.country_code
64
+ )
65
+ end
66
+
67
+ Artifact.new(
68
+ data: hit.ip,
69
+ source: source,
70
+ autonomous_system: as,
71
+ geolocation: geolocation
72
+ )
45
73
  end
46
74
 
47
75
  def configuration_keys
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "onyphe"
4
+ require "normalize_country"
4
5
 
5
6
  module Mihari
6
7
  module Analyzers
@@ -11,14 +12,13 @@ module Mihari
11
12
  option :tags, default: proc { [] }
12
13
 
13
14
  def artifacts
14
- results = search
15
- return [] unless results
15
+ responses = search
16
+ return [] unless responses
16
17
 
17
- flat_results = results.map do |result|
18
- result["results"]
19
- end.flatten.compact
20
-
21
- flat_results.filter_map { |result| result["ip"] }.uniq
18
+ results = responses.map(&:results).flatten
19
+ results.map do |result|
20
+ build_artifact result
21
+ end
22
22
  end
23
23
 
24
24
  private
@@ -34,7 +34,8 @@ module Mihari
34
34
  end
35
35
 
36
36
  def search_with_page(query, page: 1)
37
- api.simple.datascan(query, page: page)
37
+ res = api.simple.datascan(query, page: page)
38
+ Structs::Onyphe::Response.from_dynamic!(res)
38
39
  end
39
40
 
40
41
  def search
@@ -42,11 +43,35 @@ module Mihari
42
43
  (1..Float::INFINITY).each do |page|
43
44
  res = search_with_page(query, page: page)
44
45
  responses << res
45
- total = res["total"].to_i
46
+
47
+ total = res.total
46
48
  break if total <= page * PAGE_SIZE
47
49
  end
48
50
  responses
49
51
  end
52
+
53
+ #
54
+ # Build an artifact from an Onyphe search API result
55
+ #
56
+ # @param [Structs::Onyphe::Result] result
57
+ #
58
+ # @return [Artifact]
59
+ #
60
+ def build_artifact(result)
61
+ as = AutonomousSystem.new(asn: normalize_asn(result.asn))
62
+
63
+ geolocation = Geolocation.new(
64
+ country: NormalizeCountry(result.country_code, to: :short),
65
+ country_code: result.country_code
66
+ )
67
+
68
+ Artifact.new(
69
+ data: result.ip,
70
+ source: source,
71
+ autonomous_system: as,
72
+ geolocation: geolocation
73
+ )
74
+ end
50
75
  end
51
76
  end
52
77
  end
@@ -14,12 +14,11 @@ module Mihari
14
14
  results = search
15
15
  return [] unless results || results.empty?
16
16
 
17
+ results = results.map { |result| Structs::Shodan::Result.from_dynamic!(result) }
17
18
  results.map do |result|
18
- matches = result["matches"] || []
19
- matches.filter_map do |match|
20
- match["ip_str"]
21
- end
22
- end.flatten.compact.uniq
19
+ matches = result.matches || []
20
+ matches.map { |match| build_artifact match }
21
+ end.flatten.compact.uniq(&:data)
23
22
  end
24
23
 
25
24
  private
@@ -57,6 +56,28 @@ module Mihari
57
56
  end
58
57
  responses
59
58
  end
59
+
60
+ #
61
+ # Build an artifact from a Shodan search API response
62
+ #
63
+ # @param [Structs::Shodan::Match] match
64
+ #
65
+ # @return [Artifact]
66
+ #
67
+ def build_artifact(match)
68
+ as = AutonomousSystem.new(asn: normalize_asn(match.asn))
69
+ geolocation = Geolocation.new(
70
+ country: match.location.country_name,
71
+ country_code: match.location.country_code
72
+ )
73
+
74
+ Artifact.new(
75
+ data: match.ip_str,
76
+ source: source,
77
+ autonomous_system: as,
78
+ geolocation: geolocation
79
+ )
80
+ end
60
81
  end
61
82
  end
62
83
  end
File without changes
@@ -32,12 +32,48 @@ class InitialSchema < ActiveRecord::Migration[6.1]
32
32
  end
33
33
  end
34
34
 
35
- class V3Schema < ActiveRecord::Migration[6.1]
35
+ class AddeSourceToArtifactSchema < ActiveRecord::Migration[6.1]
36
36
  def change
37
37
  add_column :artifacts, :source, :string, if_not_exists: true
38
38
  end
39
39
  end
40
40
 
41
+ class EnrichmentsSchema < ActiveRecord::Migration[6.1]
42
+ def change
43
+ create_table :autonomous_systems, if_not_exists: true do |t|
44
+ t.integer :asn, null: false
45
+ t.belongs_to :artifact, foreign_key: true
46
+ end
47
+
48
+ create_table :geolocations, if_not_exists: true do |t|
49
+ t.string :country, null: false
50
+ t.string :country_code, null: false
51
+ t.belongs_to :artifact, foreign_key: true
52
+ end
53
+
54
+ create_table :whois_records, if_not_exists: true do |t|
55
+ t.string :domain, null: false
56
+ t.date :created_on
57
+ t.date :updated_on
58
+ t.date :expires_on
59
+ t.json :registrar
60
+ t.json :contacts
61
+ t.belongs_to :artifact, foreign_key: true
62
+ end
63
+
64
+ create_table :dns_records, if_not_exists: true do |t|
65
+ t.string :resource, null: false
66
+ t.string :value, null: false
67
+ t.belongs_to :artifact, foreign_key: true
68
+ end
69
+
70
+ create_table :reverse_dns_names, if_not_exists: true do |t|
71
+ t.string :name, null: false
72
+ t.belongs_to :artifact, foreign_key: true
73
+ end
74
+ end
75
+ end
76
+
41
77
  def adapter
42
78
  return "postgresql" if Mihari.config.database.start_with?("postgresql://", "postgres://")
43
79
  return "mysql2" if Mihari.config.database.start_with?("mysql2://")
@@ -59,10 +95,12 @@ module Mihari
59
95
  )
60
96
  end
61
97
 
98
+ # ActiveRecord::Base.logger = Logger.new STDOUT
62
99
  ActiveRecord::Migration.verbose = false
63
100
 
64
101
  InitialSchema.migrate(:up)
65
- V3Schema.migrate(:up)
102
+ AddeSourceToArtifactSchema.migrate(:up)
103
+ EnrichmentsSchema.migrate(:up)
66
104
  rescue StandardError
67
105
  # Do nothing
68
106
  end
@@ -76,7 +114,8 @@ module Mihari
76
114
  return unless ActiveRecord::Base.connected?
77
115
 
78
116
  InitialSchema.migrate(:down)
79
- V3Schema.migrate(:down)
117
+ AddeSourceToArtifactSchema.migrate(:down)
118
+ EnrichmentsSchema.migrate(:down)
80
119
  end
81
120
  end
82
121
  end
@@ -44,10 +44,12 @@ module Mihari
44
44
  to_at: to_at
45
45
  )
46
46
 
47
- alerts = relation.limit(limit).offset(offset).order(id: :desc)
47
+ # TODO: improve queires
48
+ alert_ids = relation.limit(limit).offset(offset).order(id: :desc).pluck(:id).uniq
49
+ alerts = includes(:artifacts, :tags).where(id: [alert_ids]).order(id: :desc)
48
50
 
49
51
  alerts.map do |alert|
50
- json = AlertSerializer.new(alert).as_json
52
+ json = Serializers::AlertSerializer.new(alert).as_json
51
53
  json[:artifacts] = json[:artifacts] || []
52
54
  json[:tags] = json[:tags] || []
53
55
  json
@@ -85,8 +87,10 @@ module Mihari
85
87
  def build_relation(artifact_data: nil, title: nil, description: nil, source: nil, tag_name: nil, from_at: nil, to_at: nil)
86
88
  relation = self
87
89
 
88
- relation = relation.joins(:artifacts).where(artifacts: { data: artifact_data }) if artifact_data
89
- relation = relation.joins(:tags).where(tags: { name: tag_name }) if tag_name
90
+ relation = relation.includes(:artifacts, :tags)
91
+
92
+ relation = relation.where(artifacts: { data: artifact_data }) if artifact_data
93
+ relation = relation.where(tags: { name: tag_name }) if tag_name
90
94
 
91
95
  relation = relation.where(source: source) if source
92
96
  relation = relation.where(title: title) if title
@@ -4,6 +4,7 @@ require "active_record"
4
4
  require "active_record/filter"
5
5
  require "active_support/core_ext/integer/time"
6
6
  require "active_support/core_ext/numeric/time"
7
+ require "uri"
7
8
 
8
9
  class ArtifactValidator < ActiveModel::Validator
9
10
  def validate(record)
@@ -15,6 +16,13 @@ end
15
16
 
16
17
  module Mihari
17
18
  class Artifact < ActiveRecord::Base
19
+ has_one :autonomous_system, dependent: :destroy
20
+ has_one :geolocation, dependent: :destroy
21
+ has_one :whois_record, dependent: :destroy
22
+
23
+ has_many :dns_records, dependent: :destroy
24
+ has_many :reverse_dns_names, dependent: :destroy
25
+
18
26
  include ActiveModel::Validations
19
27
 
20
28
  validates_with ArtifactValidator
@@ -44,5 +52,52 @@ module Mihari
44
52
  # within {ignore_threshold} days, do not ignore it
45
53
  artifact.created_at < days_before
46
54
  end
55
+
56
+ #
57
+ # Enrich(add) whois record
58
+ #
59
+ def enrich_whois
60
+ return unless can_enrich_whois?
61
+
62
+ self.whois_record = WhoisRecord.build_by_domain(normalize_as_domain(data))
63
+ end
64
+
65
+ #
66
+ # Enrich(add) DNS records
67
+ #
68
+ def enrich_dns
69
+ return unless can_enrich_dns?
70
+
71
+ self.dns_records = DnsRecord.build_by_domain(normalize_as_domain(data))
72
+ end
73
+
74
+ #
75
+ # Enrich(add) reverse DNS names
76
+ #
77
+ def enrich_reverse_dns
78
+ return unless can_enrich_revese_dns?
79
+
80
+ self.reverse_dns_names = ReverseDnsName.build_by_ip(data)
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_as_domain(url_or_domain)
86
+ return url_or_domain if data_type == "domain"
87
+
88
+ URI.parse(url_or_domain).host
89
+ end
90
+
91
+ def can_enrich_whois?
92
+ %w[domain url].include? data_type
93
+ end
94
+
95
+ def can_enrich_dns?
96
+ %w[domain url].include? data_type
97
+ end
98
+
99
+ def can_enrich_revese_dns?
100
+ data_type == "ip"
101
+ end
47
102
  end
48
103
  end