mihari 3.7.1 → 3.9.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: 50093699c012400a3f870f4bc44e373d8b2bf897ba47026f68f91c0899eaa42a
4
- data.tar.gz: d690e4c92aa71ed193dfce5576b305698a74d3cf5ca99e0e6b7e696fd502254f
3
+ metadata.gz: 47f7a6231abe8eee1a1a4e4bc6f905d5f08fba2b68b1435d1621ab85e9e583c0
4
+ data.tar.gz: 591a8c71130095d6ac963fb333179d087f00727f45ab545e4a674a3c42a2af46
5
5
  SHA512:
6
- metadata.gz: 27d489befd3a96dd8da1a8c5117f148500cd76acd66124c2d9111d15aecbb92e52ac5432edb1f7af1a69d4241ed90698aba615a802a89f2d20f2caf17154752e
7
- data.tar.gz: 1f1cf73cb2eabb643d6be55786aa2e4e598c19d2e6de0c6467ea49667c04596e4366a8c301efb189f125429e05ba2e96e8ba8c79baf775e474ed0869705d1048
6
+ metadata.gz: da1c99c406086a9d8d029f8421a3316a5a6976c5b8fbc839a910bd0d61f04a3297614582974dbda925722308d206db9d2889fe44ae213df2650f1482cc15e918
7
+ data.tar.gz: edf130cb5027bbb6abfea72b150266b8768494b627162da814c93b38d54fe2597a870d78cd9f8f33238512e5a3205cb84a67b9da0589b71393b67fef062ad9b1
data/README.md CHANGED
@@ -46,7 +46,7 @@ Mihari supports the following services by default.
46
46
  - [Shodan](https://shodan.io)
47
47
  - [Spyse](https://spyse.com)
48
48
  - [urlscan.io](https://urlscan.io)
49
- - [VirusTotal](http://virustotal.com)
49
+ - [VirusTotal](http://virustotal.com) & [VirusTotal Intelligence](https://www.virustotal.com/gui/intelligence-overview)
50
50
  - [ZoomEye](https://zoomeye.org)
51
51
 
52
52
  ## Docs
@@ -64,5 +64,3 @@ 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
@@ -4,6 +4,30 @@ require "uuidtools"
4
4
 
5
5
  module Mihari
6
6
  module Analyzers
7
+ ANALYZER_TO_CLASS = {
8
+ "binaryedge" => BinaryEdge,
9
+ "censys" => Censys,
10
+ "circl" => CIRCL,
11
+ "crtsh" => Crtsh,
12
+ "dnpedia" => DNPedia,
13
+ "dnstwister" => DNSTwister,
14
+ "onyphe" => Onyphe,
15
+ "otx" => OTX,
16
+ "passivetotal" => PassiveTotal,
17
+ "pt" => PassiveTotal,
18
+ "pulsedive" => Pulsedive,
19
+ "securitytrails" => SecurityTrails,
20
+ "shodan" => Shodan,
21
+ "spyse" => Spyse,
22
+ "st" => SecurityTrails,
23
+ "urlscan" => Urlscan,
24
+ "virustotal_intelligence" => VirusTotalIntelligence,
25
+ "virustotal" => VirusTotal,
26
+ "vt_intel" => VirusTotalIntelligence,
27
+ "vt" => VirusTotal,
28
+ "zoomeye" => ZoomEye
29
+ }.freeze
30
+
7
31
  class Rule < Base
8
32
  include Mihari::Mixins::DisallowedDataValue
9
33
 
@@ -26,25 +50,6 @@ module Mihari
26
50
  validate_analyzer_configurations
27
51
  end
28
52
 
29
- ANALYZER_TO_CLASS = {
30
- "binaryedge" => BinaryEdge,
31
- "censys" => Censys,
32
- "circl" => CIRCL,
33
- "crtsh" => Crtsh,
34
- "dnpedia" => DNPedia,
35
- "dnstwister" => DNSTwister,
36
- "onyphe" => Onyphe,
37
- "otx" => OTX,
38
- "passivetotal" => PassiveTotal,
39
- "pulsedive" => Pulsedive,
40
- "securitytrails" => SecurityTrails,
41
- "shodan" => Shodan,
42
- "spyse" => Spyse,
43
- "urlscan" => Urlscan,
44
- "virustotal" => VirusTotal,
45
- "zoomeye" => ZoomEye
46
- }.freeze
47
-
48
53
  #
49
54
  # Returns a list of artifacts matched with queries
50
55
  #
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "virustotal"
4
+
5
+ module Mihari
6
+ module Analyzers
7
+ class VirusTotalIntelligence < Base
8
+ param :query
9
+ option :title, default: proc { "VirusTotal Intelligence search" }
10
+ option :description, default: proc { "query = #{query}" }
11
+ option :tags, default: proc { [] }
12
+
13
+ def initialize(*args, **kwargs)
14
+ super
15
+
16
+ @query = query
17
+ end
18
+
19
+ def artifacts
20
+ responses = search_witgh_cursor
21
+ responses.map do |response|
22
+ response.data.map(&:value)
23
+ end.flatten.compact.uniq
24
+ end
25
+
26
+ private
27
+
28
+ def configuration_keys
29
+ %w[virustotal_api_key]
30
+ end
31
+
32
+ #
33
+ # VT API
34
+ #
35
+ # @return [::VirusTotal::API]
36
+ #
37
+ def api
38
+ @api = ::VirusTotal::API.new(key: Mihari.config.virustotal_api_key)
39
+ end
40
+
41
+ #
42
+ # Search with cursor
43
+ #
44
+ # @return [Array<Structs::VirusTotalIntelligence::Response>]
45
+ #
46
+ def search_witgh_cursor
47
+ cursor = nil
48
+ responses = []
49
+
50
+ loop do
51
+ response = Structs::VirusTotalIntelligence::Response.from_dynamic!(api.intelligence.search(query, cursor: cursor))
52
+ responses << response
53
+
54
+ break if response.meta.cursor.nil?
55
+
56
+ cursor = response.meta.cursor
57
+ end
58
+
59
+ responses
60
+ end
61
+ end
62
+ end
63
+ end
@@ -14,6 +14,7 @@ require "mihari/commands/securitytrails"
14
14
  require "mihari/commands/shodan"
15
15
  require "mihari/commands/spyse"
16
16
  require "mihari/commands/urlscan"
17
+ require "mihari/commands/virustotal_intelligence"
17
18
  require "mihari/commands/virustotal"
18
19
  require "mihari/commands/zoomeye"
19
20
 
@@ -42,6 +43,7 @@ module Mihari
42
43
  include Mihari::Commands::Spyse
43
44
  include Mihari::Commands::Urlscan
44
45
  include Mihari::Commands::VirusTotal
46
+ include Mihari::Commands::VirusTotalIntelligence
45
47
  include Mihari::Commands::ZoomEye
46
48
  end
47
49
  end
@@ -14,6 +14,7 @@ module Mihari
14
14
  run_analyzer Analyzers::PassiveTotal, query: indicator, options: options
15
15
  end
16
16
  end
17
+ map "pt" => :passivetotal
17
18
  end
18
19
  end
19
20
  end
@@ -14,6 +14,7 @@ module Mihari
14
14
  run_analyzer Analyzers::VirusTotal, query: indiactor, options: options
15
15
  end
16
16
  end
17
+ map "vt" => :virustotal
17
18
  end
18
19
  end
19
20
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mihari
4
+ module Commands
5
+ module VirusTotalIntelligence
6
+ def self.included(thor)
7
+ thor.class_eval do
8
+ desc "virustotal_intelligence [QUERY]", "VirusTotal Intelligence search"
9
+ method_option :title, type: :string, desc: "title"
10
+ method_option :description, type: :string, desc: "description"
11
+ method_option :tags, type: :array, desc: "tags"
12
+ def virustotal_intelligence(query)
13
+ with_error_handling do
14
+ run_analyzer Analyzers::VirusTotalIntelligence, query: query, options: options
15
+ end
16
+ end
17
+ map "vt_intel" => :virustotal_intelligence
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -6,19 +6,23 @@ module Mihari
6
6
  def self.included(thor)
7
7
  thor.class_eval do
8
8
  desc "web", "Launch the web app"
9
- method_option :port, type: :numeric, default: 9292
10
- method_option :host, type: :string, default: "localhost"
9
+ method_option :port, type: :numeric, default: 9292, desc: "Hostname to listen on"
10
+ method_option :host, type: :string, default: "localhost", desc: "Port to listen on"
11
+ method_option :threads, type: :string, default: "0:16", desc: "min:max threads to use"
12
+ method_option :verbose, type: :boolean, default: true, desc: "Report each request"
11
13
  method_option :config, type: :string, desc: "Path to the config file"
12
14
  def web
13
- port = options["port"].to_i || 9292
14
- host = options["host"] || "localhost"
15
+ port = options["port"]
16
+ host = options["host"]
17
+ threads = options["threads"]
18
+ verbose = options["verbose"]
15
19
 
16
20
  load_configuration
17
21
 
18
22
  # set rack env as production
19
23
  ENV["RACK_ENV"] ||= "production"
20
24
 
21
- Mihari::App.run!(port: port, host: host)
25
+ Mihari::App.run!(port: port, host: host, threads: threads, verbose: verbose)
22
26
  end
23
27
  end
24
28
  end
@@ -106,7 +106,7 @@ module Mihari
106
106
  )
107
107
  end
108
108
 
109
- # ActiveRecord::Base.logger = Logger.new STDOUT
109
+ ActiveRecord::Base.logger = Logger.new($stdout) if ENV["RACK_ENV"] == "development"
110
110
  ActiveRecord::Migration.verbose = false
111
111
 
112
112
  InitialSchema.migrate(:up)
@@ -13,36 +13,20 @@ module Mihari
13
13
  #
14
14
  # Search alerts
15
15
  #
16
- # @param [String, nil] artifact_data
17
- # @param [String, nil] description
18
- # @param [String, nil] source
19
- # @param [String, nil] tag_name
20
- # @param [String, nil] title
21
- # @param [DateTime, nil] from_at
22
- # @param [DateTime, nil] to_at
23
- # @param [Integer, nil] limit
24
- # @param [Integer, nil] page
16
+ # @param [Structs::Alert::SearchFilterWithPagination] filter
25
17
  #
26
18
  # @return [Array<Hash>]
27
19
  #
28
- def search(artifact_data: nil, description: nil, source: nil, tag_name: nil, title: nil, from_at: nil, to_at: nil, limit: 10, page: 1)
29
- limit = limit.to_i
20
+ def search(filter)
21
+ limit = filter.limit.to_i
30
22
  raise ArgumentError, "limit should be bigger than zero" unless limit.positive?
31
23
 
32
- page = page.to_i
24
+ page = filter.page.to_i
33
25
  raise ArgumentError, "page should be bigger than zero" unless page.positive?
34
26
 
35
27
  offset = (page - 1) * limit
36
28
 
37
- relation = build_relation(
38
- artifact_data: artifact_data,
39
- title: title,
40
- description: description,
41
- source: source,
42
- tag_name: tag_name,
43
- from_at: from_at,
44
- to_at: to_at
45
- )
29
+ relation = build_relation(filter.without_pagination)
46
30
 
47
31
  # TODO: improve queires
48
32
  alert_ids = relation.limit(limit).offset(offset).order(id: :desc).pluck(:id).uniq
@@ -60,45 +44,43 @@ module Mihari
60
44
  # Count alerts
61
45
  #
62
46
  # @param [String, nil] artifact_data
63
- # @param [String, nil] description
64
- # @param [String, nil] source
65
- # @param [String, nil] tag_name
66
- # @param [String, nil] title
67
- # @param [DateTime, nil] from_at
68
- # @param [DateTime, nil] to_at
69
47
  #
70
48
  # @return [Integer]
71
49
  #
72
- def count(artifact_data: nil, description: nil, source: nil, tag_name: nil, title: nil, from_at: nil, to_at: nil)
73
- relation = build_relation(
74
- artifact_data: artifact_data,
75
- title: title,
76
- description: description,
77
- source: source,
78
- tag_name: tag_name,
79
- from_at: from_at,
80
- to_at: to_at
81
- )
50
+ def count(filter)
51
+ relation = build_relation(filter)
82
52
  relation.distinct("alerts.id").count
83
53
  end
84
54
 
85
55
  private
86
56
 
87
- def build_relation(artifact_data: nil, title: nil, description: nil, source: nil, tag_name: nil, from_at: nil, to_at: nil)
88
- relation = self
57
+ def build_relation(filter)
58
+ artifact_ids = []
59
+ artifact = Artifact.includes(:autonomous_system, :dns_records, :reverse_dns_names)
60
+ artifact = artifact.where(data: filter.artifact_data) if filter.artifact_data
61
+ artifact = artifact.where(autonomous_system: { asn: filter.asn }) if filter.asn
62
+ artifact = artifact.where(dns_records: { value: filter.dns_record }) if filter.dns_record
63
+ artifact = artifact.where(reverse_dns_names: { name: filter.reverse_dns_name }) if filter.reverse_dns_name
64
+ # get artifact ids if there is any valid filter for artifact
65
+ if filter.has_valid_artifact_filters
66
+ artifact_ids = artifact.pluck(:id)
67
+ # set invalid ID if nothing is matched with the filters
68
+ artifact_ids = [-1] if artifact_ids.empty?
69
+ end
89
70
 
71
+ relation = self
90
72
  relation = relation.includes(:artifacts, :tags)
91
73
 
92
- relation = relation.where(artifacts: { data: artifact_data }) if artifact_data
93
- relation = relation.where(tags: { name: tag_name }) if tag_name
74
+ relation = relation.where(artifacts: { id: artifact_ids }) unless artifact_ids.empty?
75
+ relation = relation.where(tags: { name: filter.tag_name }) if filter.tag_name
94
76
 
95
- relation = relation.where(source: source) if source
96
- relation = relation.where(title: title) if title
77
+ relation = relation.where(source: filter.source) if filter.source
78
+ relation = relation.where(title: filter.title) if filter.title
97
79
 
98
- relation = relation.filter(description: { like: "%#{description}%" }) if description
80
+ relation = relation.filter(description: { like: "%#{filter.description}%" }) if filter.description
99
81
 
100
- relation = relation.filter(created_at: { gte: from_at }) if from_at
101
- relation = relation.filter(created_at: { lte: to_at }) if to_at
82
+ relation = relation.filter(created_at: { gte: filter.from_at }) if filter.from_at
83
+ relation = relation.filter(created_at: { lte: filter.to_at }) if filter.to_at
102
84
 
103
85
  relation
104
86
  end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+ require "dry/struct"
3
+
4
+ module Mihari
5
+ module Structs
6
+ module Alert
7
+ class SearchFilter < Dry::Struct
8
+ attribute? :artifact_data, Types::String.optional
9
+ attribute? :description, Types::String.optional
10
+ attribute? :source, Types::String.optional
11
+ attribute? :tag_name, Types::String.optional
12
+ attribute? :title, Types::String.optional
13
+ attribute? :from_at, Types::DateTime.optional
14
+ attribute? :to_at, Types::DateTime.optional
15
+ attribute? :asn, Types::Int.optional
16
+ attribute? :dns_record, Types::String.optional
17
+ attribute? :reverse_dns_name, Types::String.optional
18
+
19
+ def has_valid_artifact_filters
20
+ !(artifact_data || asn || dns_record || reverse_dns_name).nil?
21
+ end
22
+ end
23
+
24
+ class SearchFilterWithPagination < SearchFilter
25
+ attribute? :page, Types::Int.default(1)
26
+ attribute? :limit, Types::Int.default(10)
27
+
28
+ def without_pagination
29
+ SearchFilter.new(
30
+ artifact_data: artifact_data,
31
+ description: description,
32
+ from_at: from_at,
33
+ source: source,
34
+ tag_name: tag_name,
35
+ title: title,
36
+ to_at: to_at,
37
+ asn: asn,
38
+ dns_record: dns_record,
39
+ reverse_dns_name: reverse_dns_name
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ require "json"
2
+ require "dry/struct"
3
+
4
+ module Mihari
5
+ module Structs
6
+ module VirusTotalIntelligence
7
+ class ContextAttributes < Dry::Struct
8
+ attribute :url, Types.Array(Types::String).optional
9
+
10
+ def self.from_dynamic!(d)
11
+ d = Types::Hash[d]
12
+ new(
13
+ url: d["url"]
14
+ )
15
+ end
16
+ end
17
+
18
+ class Datum < Dry::Struct
19
+ attribute :type, Types::String
20
+ attribute :id, Types::String
21
+ attribute :context_attributes, ContextAttributes.optional
22
+
23
+ def value
24
+ case type
25
+ when "file"
26
+ id
27
+ when "url"
28
+ (context_attributes.url || []).first
29
+ when "domain"
30
+ id
31
+ when "ip_address"
32
+ id
33
+ end
34
+ end
35
+
36
+ def self.from_dynamic!(d)
37
+ d = Types::Hash[d]
38
+
39
+ context_attributes = nil
40
+ context_attributes = ContextAttributes.from_dynamic!(d.fetch("context_attributes")) if d.key?("context_attributes")
41
+
42
+ new(
43
+ type: d.fetch("type"),
44
+ id: d.fetch("id"),
45
+ context_attributes: context_attributes
46
+ )
47
+ end
48
+ end
49
+
50
+ class Meta < Dry::Struct
51
+ attribute :cursor, Types::String.optional
52
+
53
+ def self.from_dynamic!(d)
54
+ d = Types::Hash[d]
55
+ new(
56
+ cursor: d["cursor"]
57
+ )
58
+ end
59
+ end
60
+
61
+ class Response < Dry::Struct
62
+ attribute :meta, Meta
63
+ attribute :data, Types.Array(Datum)
64
+
65
+ def self.from_dynamic!(d)
66
+ d = Types::Hash[d]
67
+ new(
68
+ meta: Meta.from_dynamic!(d.fetch("meta")),
69
+ data: d.fetch("data").map { |x| Datum.from_dynamic!(x) }
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/mihari/types.rb CHANGED
@@ -9,13 +9,28 @@ module Mihari
9
9
  Hash = Strict::Hash
10
10
  String = Strict::String
11
11
  Double = Strict::Float | Strict::Integer
12
+ DateTime = Strict::DateTime
12
13
 
13
14
  DataTypes = Types::String.enum(*ALLOWED_DATA_TYPES)
14
15
 
15
16
  AnalyzerTypes = Types::String.enum(
16
- "binaryedge", "censys", "circl", "dnpedia", "dnstwister",
17
- "onyphe", "otx", "passivetotal", "pulsedive", "securitytrails",
18
- "shodan", "virustotal"
17
+ "binaryedge",
18
+ "censys",
19
+ "circl",
20
+ "dnpedia",
21
+ "dnstwister",
22
+ "onyphe",
23
+ "otx",
24
+ "passivetotal",
25
+ "pt",
26
+ "pulsedive",
27
+ "securitytrails",
28
+ "shodan",
29
+ "st",
30
+ "virustotal_intelligence",
31
+ "virustotal",
32
+ "vt_intel",
33
+ "vt"
19
34
  )
20
35
  end
21
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mihari
4
- VERSION = "3.7.1"
4
+ VERSION = "3.9.0"
5
5
  end
@@ -37,10 +37,10 @@ module Mihari
37
37
  use Mihari::Controllers::TagsController
38
38
 
39
39
  class << self
40
- def run!(port: 9292, host: "localhost")
40
+ def run!(port: 9292, host: "localhost", threads: "0:16", verbose: false)
41
41
  url = "http://#{host}:#{port}"
42
42
 
43
- Rack::Handler::Puma.run self, Port: port, Host: host do |server|
43
+ Rack::Handler::Puma.run(self, Port: port, Host: host, Threads: threads, Verbose: verbose) do |server|
44
44
  Launchy.open url
45
45
 
46
46
  [:INT, :TERM].each do |sig|
@@ -15,39 +15,32 @@ module Mihari
15
15
  param :to_at, DateTime
16
16
  param :toAt, DateTime
17
17
 
18
+ param :asn, Integer
19
+ param :dns_record, String
20
+ param :dnsRecord, String
21
+ param :reverse_dns_name, String
22
+ param :reverseDnsName, String
23
+
24
+ # set page & limit
18
25
  page = params["page"] || 1
19
- page = page.to_i
26
+ params["page"] = page.to_i
27
+
20
28
  limit = 10
29
+ params["limit"] = 10
21
30
 
22
- artifact_data = params["artifact"]
23
- description = params["description"]
24
- source = params["source"]
25
- tag_name = params["tag"]
26
- title = params["title"]
31
+ # normalize keys
32
+ params["artifact_data"] = params["artifact"]
33
+ params["from_at"] = params["from_at"] || params["fromAt"]
34
+ params["to_at"] = params["to_at"] || params["toAt"]
35
+ params["dns_record"] = params["dns_record"] || params["dnsRecord"]
36
+ params["reverse_dns_name"] = params["reverse_dns_name"] || params["reverseDnsName"]
27
37
 
28
- from_at = params["from_at"] || params["fromAt"]
29
- to_at = params["to_at"] || params["toAt"]
38
+ # symbolize hash keys
39
+ filter = params.to_h.transform_keys(&:to_sym)
30
40
 
31
- alerts = Mihari::Alert.search(
32
- artifact_data: artifact_data,
33
- description: description,
34
- from_at: from_at,
35
- limit: limit,
36
- page: page,
37
- source: source,
38
- tag_name: tag_name,
39
- title: title,
40
- to_at: to_at
41
- )
42
- total = Mihari::Alert.count(
43
- artifact_data: artifact_data,
44
- description: description,
45
- from_at: from_at,
46
- source: source,
47
- tag_name: tag_name,
48
- title: title,
49
- to_at: to_at
50
- )
41
+ search_filter_with_pagenation = Structs::Alert::SearchFilterWithPagination.new(**filter)
42
+ alerts = Mihari::Alert.search(search_filter_with_pagenation)
43
+ total = Mihari::Alert.count(search_filter_with_pagenation.without_pagination)
51
44
 
52
45
  json({ alerts: alerts, total: total, current_page: page, page_size: limit })
53
46
  end
@@ -1 +1 @@
1
- <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/static/favicon.ico"><title>Mihari</title><link href="/static/js/app.06d5cf1c.js" rel="preload" as="script"></head><body><noscript><strong>We're sorry but Mihari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/app.06d5cf1c.js"></script></body></html>
1
+ <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/static/favicon.ico"><title>Mihari</title><link href="/static/js/app.378da3dc.js" rel="preload" as="script"></head><body><noscript><strong>We're sorry but Mihari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/app.378da3dc.js"></script></body></html>