mihari 5.1.0 → 5.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.gitmodules +0 -3
  3. data/.rubocop.yml +6 -0
  4. data/README.md +0 -1
  5. data/lib/mihari/analyzers/base.rb +32 -27
  6. data/lib/mihari/analyzers/binaryedge.rb +17 -9
  7. data/lib/mihari/analyzers/censys.rb +10 -54
  8. data/lib/mihari/analyzers/circl.rb +7 -6
  9. data/lib/mihari/analyzers/crtsh.rb +12 -7
  10. data/lib/mihari/analyzers/dnstwister.rb +7 -7
  11. data/lib/mihari/analyzers/feed.rb +33 -10
  12. data/lib/mihari/analyzers/greynoise.rb +8 -33
  13. data/lib/mihari/analyzers/onyphe.rb +10 -36
  14. data/lib/mihari/analyzers/otx.rb +4 -3
  15. data/lib/mihari/analyzers/passivetotal.rb +8 -7
  16. data/lib/mihari/analyzers/pulsedive.rb +8 -7
  17. data/lib/mihari/analyzers/rule.rb +0 -1
  18. data/lib/mihari/analyzers/securitytrails.rb +8 -10
  19. data/lib/mihari/analyzers/shodan.rb +16 -90
  20. data/lib/mihari/analyzers/urlscan.rb +16 -6
  21. data/lib/mihari/analyzers/virustotal.rb +8 -6
  22. data/lib/mihari/analyzers/virustotal_intelligence.rb +12 -7
  23. data/lib/mihari/analyzers/zoomeye.rb +13 -10
  24. data/lib/mihari/clients/base.rb +53 -0
  25. data/lib/mihari/clients/binaryedge.rb +38 -0
  26. data/lib/mihari/clients/censys.rb +42 -0
  27. data/lib/mihari/clients/circl.rb +59 -0
  28. data/lib/mihari/clients/crtsh.rb +31 -0
  29. data/lib/mihari/clients/dnstwister.rb +40 -0
  30. data/lib/mihari/clients/greynoise.rb +34 -0
  31. data/lib/mihari/clients/misp.rb +29 -0
  32. data/lib/mihari/clients/onyphe.rb +35 -0
  33. data/lib/mihari/clients/otx.rb +49 -0
  34. data/lib/mihari/clients/passivetotal.rb +69 -0
  35. data/lib/mihari/clients/publsedive.rb +56 -0
  36. data/lib/mihari/clients/securitytrails.rb +94 -0
  37. data/lib/mihari/clients/shodan.rb +41 -0
  38. data/lib/mihari/clients/the_hive.rb +33 -0
  39. data/lib/mihari/clients/urlscan.rb +33 -0
  40. data/lib/mihari/clients/virustotal.rb +62 -0
  41. data/lib/mihari/clients/zoomeye.rb +74 -0
  42. data/lib/mihari/commands/database.rb +1 -6
  43. data/lib/mihari/commands/searcher.rb +1 -2
  44. data/lib/mihari/database.rb +9 -0
  45. data/lib/mihari/emitters/misp.rb +13 -20
  46. data/lib/mihari/emitters/the_hive.rb +3 -5
  47. data/lib/mihari/emitters/webhook.rb +2 -2
  48. data/lib/mihari/feed/reader.rb +14 -11
  49. data/lib/mihari/http.rb +29 -21
  50. data/lib/mihari/mixins/retriable.rb +3 -1
  51. data/lib/mihari/schemas/analyzer.rb +5 -4
  52. data/lib/mihari/structs/censys.rb +62 -0
  53. data/lib/mihari/structs/greynoise.rb +43 -0
  54. data/lib/mihari/structs/onyphe.rb +45 -0
  55. data/lib/mihari/structs/shodan.rb +83 -0
  56. data/lib/mihari/version.rb +1 -1
  57. data/lib/mihari/web/middleware/connection_adapter.rb +1 -3
  58. data/lib/mihari/web/public/assets/{index-63900d73.js → index-7d0fb8c4.js} +2 -2
  59. data/lib/mihari/web/public/index.html +1 -1
  60. data/lib/mihari/web/public/redoc-static.html +2 -2
  61. data/lib/mihari.rb +21 -2
  62. data/mihari.gemspec +15 -23
  63. metadata +55 -264
  64. data/lib/mihari/analyzers/clients/otx.rb +0 -36
  65. data/lib/mihari/analyzers/dnpedia.rb +0 -37
  66. data/lib/mihari/mixins/database.rb +0 -16
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mihari
4
+ module Clients
5
+ class ZoomEye < Base
6
+ attr_reader :api_key
7
+
8
+ #
9
+ # @param [String] base_url
10
+ # @param [String, nil] api_key
11
+ # @param [Hash] headers
12
+ #
13
+ def initialize(base_url = "https://api.zoomeye.org", api_key:, headers: {})
14
+ raise(ArgumentError, "'api_key' argument is required") unless api_key
15
+
16
+ headers["api-key"] = api_key
17
+ super(base_url, headers: headers)
18
+ end
19
+
20
+ #
21
+ # Search the Host devices
22
+ #
23
+ # @param [String] query Query string
24
+ # @param [Integer, nil] page The page number to paging(default:1)
25
+ # @param [String, nil] facets A comma-separated list of properties to get summary information on query
26
+ #
27
+ # @return [Hash]
28
+ #
29
+ def host_search(query, page: nil, facets: nil)
30
+ params = {
31
+ query: query,
32
+ page: page,
33
+ facets: facets
34
+ }.compact
35
+
36
+ _get("/host/search", params: params)
37
+ end
38
+
39
+ #
40
+ # Search the Web technologies
41
+ #
42
+ # @param [String] query Query string
43
+ # @param [Integer, nil] page The page number to paging(default:1)
44
+ # @param [String, nil] facets A comma-separated list of properties to get summary information on query
45
+ #
46
+ # @return [Hash]
47
+ #
48
+ def web_search(query, page: nil, facets: nil)
49
+ params = {
50
+ query: query,
51
+ page: page,
52
+ facets: facets
53
+ }.compact
54
+
55
+ _get("/web/search", params: params)
56
+ end
57
+
58
+ private
59
+
60
+ #
61
+ # @param [String] path
62
+ # @param [Hash] params
63
+ #
64
+ # @return [Hash, nil]
65
+ #
66
+ def _get(path, params: {})
67
+ res = get(path, params: params)
68
+ JSON.parse(res.body.to_s)
69
+ rescue HTTPError
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -3,8 +3,6 @@
3
3
  module Mihari
4
4
  module Commands
5
5
  module Database
6
- include Mixins::Database
7
-
8
6
  def self.included(thor)
9
7
  thor.class_eval do
10
8
  desc "migrate", "Migrate DB schemas"
@@ -12,14 +10,11 @@ module Mihari
12
10
  #
13
11
  # @param [String] direction
14
12
  #
15
- #
16
13
  def migrate(direction = "up")
17
14
  verbose = options["verbose"]
18
15
  ActiveRecord::Migration.verbose = verbose
19
16
 
20
- with_db_connection do
21
- Mihari::Database.migrate(direction.to_sym)
22
- end
17
+ Mihari::Database.with_db_connection { Mihari::Database.migrate(direction.to_sym) }
23
18
  end
24
19
  end
25
20
  end
@@ -3,7 +3,6 @@
3
3
  module Mihari
4
4
  module Commands
5
5
  module Searcher
6
- include Mixins::Database
7
6
  include Mixins::ErrorNotification
8
7
 
9
8
  def self.included(thor)
@@ -16,7 +15,7 @@ module Mihari
16
15
  # @param [String] path_or_id
17
16
  #
18
17
  def search(path_or_id)
19
- with_db_connection do
18
+ Mihari::Database.with_db_connection do
20
19
  rule = Structs::Rule.from_path_or_id path_or_id
21
20
 
22
21
  # validate
@@ -164,6 +164,15 @@ module Mihari
164
164
 
165
165
  ActiveRecord::Base.clear_active_connections!
166
166
  end
167
+
168
+ def with_db_connection
169
+ Mihari::Database.connect
170
+ yield
171
+ rescue ActiveRecord::StatementInvalid
172
+ Mihari.logger.error("You haven't finished the DB migration! Please run 'mihari db migrate'.")
173
+ ensure
174
+ Mihari::Database.close
175
+ end
167
176
  end
168
177
  end
169
178
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "misp"
4
-
5
3
  module Mihari
6
4
  module Emitters
7
5
  class MISP < Base
@@ -16,11 +14,6 @@ module Mihari
16
14
 
17
15
  @url = kwargs[:url] || Mihari.config.misp_url
18
16
  @api_key = kwargs[:api_key] || Mihari.config.misp_api_key
19
-
20
- ::MISP.configure do |config|
21
- config.api_endpoint = url
22
- config.api_key = api_key
23
- end
24
17
  end
25
18
 
26
19
  # @return [Boolean]
@@ -50,17 +43,13 @@ module Mihari
50
43
  def emit(rule:, artifacts:, **_options)
51
44
  return if artifacts.empty?
52
45
 
53
- event = ::MISP::Event.new(info: rule.title)
54
-
55
- artifacts.each do |artifact|
56
- event.attributes << build_attribute(artifact)
57
- end
58
-
59
- rule.tags.each do |tag|
60
- event.add_tag name: tag
61
- end
62
-
63
- event.create
46
+ client.create_event({
47
+ Event: {
48
+ info: rule.title
49
+ },
50
+ Attribute: artifacts.map { |artifact| build_attribute(artifact) },
51
+ Tag: rule.tags.map { |tag| { name: tag } }
52
+ })
64
53
  end
65
54
 
66
55
  private
@@ -69,15 +58,19 @@ module Mihari
69
58
  %w[misp_url misp_api_key]
70
59
  end
71
60
 
61
+ def client
62
+ @client ||= Clients::MISP.new(url, api_key: api_key)
63
+ end
64
+
72
65
  #
73
66
  # Build a MISP attribute
74
67
  #
75
68
  # @param [Mihari::Artifact] artifact
76
69
  #
77
- # @return [::MISP::Attribute]
70
+ # @return [Hash]
78
71
  #
79
72
  def build_attribute(artifact)
80
- ::MISP::Attribute.new(value: artifact.data, type: to_misp_type(type: artifact.data_type, value: artifact.data))
73
+ { value: artifact.data, type: to_misp_type(type: artifact.data_type, value: artifact.data) }
81
74
  end
82
75
 
83
76
  #
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hachi"
4
-
5
3
  module Mihari
6
4
  module Emitters
7
5
  class TheHive < Base
@@ -50,7 +48,7 @@ module Mihari
50
48
  return if artifacts.empty?
51
49
 
52
50
  payload = payload(rule: rule, artifacts: artifacts)
53
- api.alert.create(**payload)
51
+ client.alert(payload)
54
52
  end
55
53
 
56
54
  #
@@ -79,8 +77,8 @@ module Mihari
79
77
  %w[thehive_url thehive_api_key]
80
78
  end
81
79
 
82
- def api
83
- @api ||= Hachi::API.new(api_endpoint: url, api_key: api_key, api_version: normalized_api_version)
80
+ def client
81
+ @client ||= Clients::TheHive.new(url, api_key: api_key, api_version: normalized_api_version)
84
82
  end
85
83
 
86
84
  #
@@ -77,13 +77,13 @@ module Mihari
77
77
  payload_ = payload_as_string(artifacts: artifacts, rule: rule)
78
78
  payload = JSON.parse(payload_)
79
79
 
80
- client = Mihari::HTTP.new(url, headers: headers, payload: payload)
80
+ client = Mihari::HTTP.new(url, headers: headers)
81
81
 
82
82
  case method
83
83
  when "GET"
84
84
  res = client.get
85
85
  when "POST"
86
- res = client.post
86
+ res = client.post(json: payload)
87
87
  end
88
88
 
89
89
  res
@@ -6,25 +6,28 @@ require "insensitive_hash"
6
6
  module Mihari
7
7
  module Feed
8
8
  class Reader
9
- attr_reader :uri, :http_request_headers, :http_request_method, :http_request_payload
9
+ attr_reader :url, :headers, :params, :json, :data, :method
10
10
 
11
- def initialize(uri, http_request_headers: {}, http_request_method: "GET", http_request_payload_type: nil, http_request_payload: {})
12
- @uri = Addressable::URI.parse(uri)
13
- @http_request_headers = http_request_headers.insensitive
14
- @http_request_method = http_request_method
15
- @http_request_payload = http_request_payload
11
+ def initialize(url, headers: {}, method: "GET", params: nil, json: nil, data: nil)
12
+ @url = Addressable::URI.parse(url)
13
+ @headers = headers.insensitive
14
+ @method = method
16
15
 
17
- http_request_headers["content-type"] = http_request_payload_type if http_request_payload_type
16
+ @params = params
17
+ @json = json
18
+ @data = data
19
+
20
+ headers["content-type"] = "application/json" unless json.nil?
18
21
  end
19
22
 
20
23
  def read
21
- return read_file(uri.path) if uri.scheme == "file"
24
+ return read_file(url.path) if url.scheme == "file"
22
25
 
23
26
  res = nil
24
- client = HTTP.new(uri, headers: http_request_headers, payload: http_request_payload)
27
+ client = HTTP.new(url, headers: headers)
25
28
 
26
- res = client.get if http_request_method == "GET"
27
- res = client.post if http_request_method == "POST"
29
+ res = client.get(params: params) if method == "GET"
30
+ res = client.post(params: params, json: json, data: data) if method == "POST"
28
31
 
29
32
  return [] if res.nil?
30
33
 
data/lib/mihari/http.rb CHANGED
@@ -4,54 +4,62 @@ require "insensitive_hash"
4
4
 
5
5
  module Mihari
6
6
  class HTTP
7
- attr_reader :url, :headers, :payload
7
+ # @return [String]
8
+ attr_reader :url
8
9
 
9
- def initialize(url, headers: {}, payload: {})
10
+ # @return [Hash]
11
+ attr_reader :headers
12
+
13
+ def initialize(url, headers: {})
10
14
  @url = url.is_a?(URI) ? url : URI(url.to_s)
11
15
  @headers = headers.insensitive
12
- @payload = payload
13
16
  end
14
17
 
15
18
  #
16
19
  # Make a GET request
17
20
  #
21
+ # @param [Hash, nil] params
22
+ #
18
23
  # @return [Net::HTTPResponse]
19
24
  #
20
- def get
25
+ def get(params: nil)
21
26
  new_url = url.deep_dup
22
- new_url.query = Addressable::URI.form_encode(payload) unless payload.empty?
27
+ new_url.query = Addressable::URI.form_encode(params) unless (params || {}).empty?
23
28
 
24
29
  get = Net::HTTP::Get.new(new_url)
25
30
  request get
26
31
  end
27
32
 
28
33
  #
29
- # Make a POST request
34
+ # Make a POST requesti
35
+ #
36
+ # @param [Hash, nil] params
37
+ # @param [Hash, nil] json
38
+ # @param [Hash, nil] data
30
39
  #
31
40
  # @return [Net::HTTPResponse]
32
41
  #
33
- def post
34
- post = Net::HTTP::Post.new(url)
35
-
36
- case content_type
37
- when "application/json"
38
- post.body = JSON.generate(payload)
39
- when "application/x-www-form-urlencoded"
40
- post.set_form_data(payload)
41
- end
42
+ def post(params: nil, json: nil, data: nil)
43
+ new_url = url.deep_dup
44
+ new_url.query = Addressable::URI.form_encode(params) unless (params || {}).empty?
45
+
46
+ post = Net::HTTP::Post.new(new_url)
47
+
48
+ post.body = JSON.generate(json) if json
49
+ post.set_form_data(data) if data
42
50
 
43
51
  request post
44
52
  end
45
53
 
46
54
  class << self
47
- def get(url, headers: {}, params: {})
48
- client = new(url, headers: headers, payload: params)
49
- client.get
55
+ def get(url, headers: {}, params: nil)
56
+ client = new(url, headers: headers)
57
+ client.get(params: params)
50
58
  end
51
59
 
52
- def post(url, headers: {}, payload: {})
53
- client = new(url, headers: headers, payload: payload)
54
- client.post
60
+ def post(url, headers: {}, params: nil, json: nil, data: nil)
61
+ client = new(url, headers: headers)
62
+ client.post(params: params, json: json, data: data)
55
63
  end
56
64
  end
57
65
 
@@ -9,7 +9,9 @@ module Mihari
9
9
  Errno::EPIPE,
10
10
  OpenSSL::SSL::SSLError,
11
11
  Timeout::Error,
12
- RetryableError
12
+ RetryableError,
13
+ NetworkError,
14
+ TimeoutError
13
15
  ]
14
16
 
15
17
  #
@@ -82,10 +82,11 @@ module Mihari
82
82
  required(:analyzer).value(Types::String.enum("feed"))
83
83
  required(:query).value(:string)
84
84
  required(:selector).value(:string)
85
- optional(:http_request_method).value(Types::HTTPRequestMethods).default("GET")
86
- optional(:http_request_headers).value(:hash).default({})
87
- optional(:http_request_payload).value(:hash).default({})
88
- optional(:http_request_payload_type).value(Types::HTTPRequestPayloadTypes)
85
+ optional(:method).value(Types::HTTPRequestMethods).default("GET")
86
+ optional(:headers).value(:hash).default({})
87
+ optional(:params).value(:hash)
88
+ optional(:data).value(:hash)
89
+ optional(:json).value(:hash)
89
90
  optional(:options).hash(AnalyzerOptions)
90
91
  end
91
92
  end
@@ -4,8 +4,17 @@ module Mihari
4
4
  module Structs
5
5
  module Censys
6
6
  class AutonomousSystem < Dry::Struct
7
+ include Mixins::AutonomousSystem
8
+
7
9
  attribute :asn, Types::Int
8
10
 
11
+ #
12
+ # @return [Mihari::AutonomousSystem]
13
+ #
14
+ def to_as
15
+ Mihari::AutonomousSystem.new(asn: normalize_asn(asn))
16
+ end
17
+
9
18
  def self.from_dynamic!(d)
10
19
  d = Types::Hash[d]
11
20
  new(
@@ -18,6 +27,20 @@ module Mihari
18
27
  attribute :country, Types::String.optional
19
28
  attribute :country_code, Types::String.optional
20
29
 
30
+ #
31
+ # @return [Mihari::Geolocation] <description>
32
+ #
33
+ def to_geolocation
34
+ # sometimes Censys overlooks country
35
+ # then set geolocation as nil
36
+ return nil if country.nil?
37
+
38
+ Mihari::Geolocation.new(
39
+ country: country,
40
+ country_code: country_code
41
+ )
42
+ end
43
+
21
44
  def self.from_dynamic!(d)
22
45
  d = Types::Hash[d]
23
46
  new(
@@ -30,6 +53,13 @@ module Mihari
30
53
  class Service < Dry::Struct
31
54
  attribute :port, Types::Integer
32
55
 
56
+ #
57
+ # @return [Mihari::Port]
58
+ #
59
+ def to_port
60
+ Port.new(port: port)
61
+ end
62
+
33
63
  def self.from_dynamic!(d)
34
64
  d = Types::Hash[d]
35
65
  new(
@@ -45,6 +75,29 @@ module Mihari
45
75
  attribute :metadata, Types::Hash
46
76
  attribute :services, Types.Array(Service)
47
77
 
78
+ #
79
+ # @return [Array<Mihari::Port>]
80
+ #
81
+ def to_ports
82
+ services.map(&:to_port)
83
+ end
84
+
85
+ #
86
+ # @param [String] source
87
+ #
88
+ # @return [Mihari::Artifact]
89
+ #
90
+ def to_artifact(source = "Censys")
91
+ Artifact.new(
92
+ data: ip,
93
+ source: source,
94
+ metadata: metadata,
95
+ autonomous_system: autonomous_system.to_as,
96
+ geolocation: location.to_geolocation,
97
+ ports: to_ports
98
+ )
99
+ end
100
+
48
101
  def self.from_dynamic!(d)
49
102
  d = Types::Hash[d]
50
103
  new(
@@ -76,6 +129,15 @@ module Mihari
76
129
  attribute :hits, Types.Array(Hit)
77
130
  attribute :links, Links
78
131
 
132
+ #
133
+ # @param [String] source
134
+ #
135
+ # @return [Array<Mihari::Artifact>]
136
+ #
137
+ def to_artifacts(source = "Censys")
138
+ hits.map { |hit| hit.to_artifact(source) }
139
+ end
140
+
79
141
  def self.from_dynamic!(d)
80
142
  d = Types::Hash[d]
81
143
  new(
@@ -4,10 +4,29 @@ module Mihari
4
4
  module Structs
5
5
  module GreyNoise
6
6
  class Metadata < Dry::Struct
7
+ include Mixins::AutonomousSystem
8
+
7
9
  attribute :country, Types::String
8
10
  attribute :country_code, Types::String
9
11
  attribute :asn, Types::String
10
12
 
13
+ #
14
+ # @return [Mihari::AutonomousSystem]
15
+ #
16
+ def to_as
17
+ Mihari::AutonomousSystem.new(asn: normalize_asn(asn))
18
+ end
19
+
20
+ #
21
+ # @return [Mihari::Geolocation]
22
+ #
23
+ def to_geolocation
24
+ Mihari::Geolocation.new(
25
+ country: country,
26
+ country_code: country_code
27
+ )
28
+ end
29
+
11
30
  def self.from_dynamic!(d)
12
31
  d = Types::Hash[d]
13
32
  new(
@@ -23,6 +42,21 @@ module Mihari
23
42
  attribute :metadata, Metadata
24
43
  attribute :metadata_, Types::Hash
25
44
 
45
+ #
46
+ # @param [String] source
47
+ #
48
+ # @return [Mihari::Artifact]
49
+ #
50
+ def to_artifact(source = "GreyNoise")
51
+ Mihari::Artifact.new(
52
+ data: ip,
53
+ source: source,
54
+ metadata: metadata_,
55
+ autonomous_system: metadata.to_as,
56
+ geolocation: metadata.to_geolocation
57
+ )
58
+ end
59
+
26
60
  def self.from_dynamic!(d)
27
61
  d = Types::Hash[d]
28
62
  new(
@@ -40,6 +74,15 @@ module Mihari
40
74
  attribute :message, Types::String
41
75
  attribute :query, Types::String
42
76
 
77
+ #
78
+ # @param [String] source
79
+ #
80
+ # @return [Array<Mihari::Artifact>]
81
+ #
82
+ def to_artifacts(source = "GreyNoise")
83
+ data.map { |datum| datum.to_artifact(source) }
84
+ end
85
+
43
86
  def self.from_dynamic!(d)
44
87
  d = Types::Hash[d]
45
88
  new(
@@ -4,11 +4,47 @@ module Mihari
4
4
  module Structs
5
5
  module Onyphe
6
6
  class Result < Dry::Struct
7
+ include Mixins::AutonomousSystem
8
+
7
9
  attribute :asn, Types::String
8
10
  attribute :country_code, Types::String.optional
9
11
  attribute :ip, Types::String
10
12
  attribute :metadata, Types::Hash
11
13
 
14
+ #
15
+ # @param [String] source
16
+ #
17
+ # @return [Mihari::Artifact]
18
+ #
19
+ def to_artifact(source = "Onyphe")
20
+ Mihari::Artifact.new(
21
+ data: ip,
22
+ source: source,
23
+ metadata: metadata,
24
+ autonomous_system: to_as,
25
+ geolocation: to_geolocation
26
+ )
27
+ end
28
+
29
+ #
30
+ # @return [Mihari::Geolocation, nil]
31
+ #
32
+ def to_geolocation
33
+ return nil if country_code.nil?
34
+
35
+ Mihari::Geolocation.new(
36
+ country: NormalizeCountry(country_code, to: :short),
37
+ country_code: country_code
38
+ )
39
+ end
40
+
41
+ #
42
+ # @return [Mihari::AutonomousSystem]
43
+ #
44
+ def to_as
45
+ Mihari::AutonomousSystem.new(asn: normalize_asn(asn))
46
+ end
47
+
12
48
  def self.from_dynamic!(d)
13
49
  d = Types::Hash[d]
14
50
  new(
@@ -30,6 +66,15 @@ module Mihari
30
66
  attribute :status, Types::String
31
67
  attribute :total, Types::Int
32
68
 
69
+ #
70
+ # @param [String] source
71
+ #
72
+ # @return [Array<Mihari::Artifact>]
73
+ #
74
+ def to_artifacts(source = "Onyphe")
75
+ results.map { |result| result.to_artifact(source) }
76
+ end
77
+
33
78
  def self.from_dynamic!(d)
34
79
  d = Types::Hash[d]
35
80
  new(