mihari 2.3.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -0
  3. data/.overcommit.yml +12 -0
  4. data/README.md +1 -9
  5. data/docker/Dockerfile +1 -1
  6. data/exe/mihari +1 -1
  7. data/lib/mihari.rb +89 -15
  8. data/lib/mihari/analyzers/base.rb +49 -8
  9. data/lib/mihari/analyzers/basic.rb +1 -2
  10. data/lib/mihari/analyzers/binaryedge.rb +7 -13
  11. data/lib/mihari/analyzers/censys.rb +26 -63
  12. data/lib/mihari/analyzers/circl.rb +20 -17
  13. data/lib/mihari/analyzers/crtsh.rb +6 -13
  14. data/lib/mihari/analyzers/dnpedia.rb +6 -12
  15. data/lib/mihari/analyzers/dnstwister.rb +13 -10
  16. data/lib/mihari/analyzers/onyphe.rb +6 -12
  17. data/lib/mihari/analyzers/otx.rb +22 -19
  18. data/lib/mihari/analyzers/passivetotal.rb +22 -21
  19. data/lib/mihari/analyzers/pulsedive.rb +16 -13
  20. data/lib/mihari/analyzers/rule.rb +97 -0
  21. data/lib/mihari/analyzers/securitytrails.rb +22 -19
  22. data/lib/mihari/analyzers/shodan.rb +7 -13
  23. data/lib/mihari/analyzers/spyse.rb +12 -19
  24. data/lib/mihari/analyzers/urlscan.rb +22 -27
  25. data/lib/mihari/analyzers/virustotal.rb +25 -22
  26. data/lib/mihari/analyzers/zoomeye.rb +14 -20
  27. data/lib/mihari/cli/analyzer.rb +44 -0
  28. data/lib/mihari/cli/base.rb +27 -0
  29. data/lib/mihari/cli/init.rb +13 -0
  30. data/lib/mihari/cli/main.rb +30 -0
  31. data/lib/mihari/cli/mixins/utils.rb +88 -0
  32. data/lib/mihari/cli/validator.rb +11 -0
  33. data/lib/mihari/commands/binaryedge.rb +1 -1
  34. data/lib/mihari/commands/censys.rb +1 -1
  35. data/lib/mihari/commands/circl.rb +2 -2
  36. data/lib/mihari/commands/crtsh.rb +1 -1
  37. data/lib/mihari/commands/dnpedia.rb +1 -1
  38. data/lib/mihari/commands/dnstwister.rb +2 -2
  39. data/lib/mihari/commands/init.rb +46 -0
  40. data/lib/mihari/commands/json.rb +1 -1
  41. data/lib/mihari/commands/onyphe.rb +1 -1
  42. data/lib/mihari/commands/otx.rb +2 -2
  43. data/lib/mihari/commands/passivetotal.rb +2 -2
  44. data/lib/mihari/commands/pulsedive.rb +2 -2
  45. data/lib/mihari/commands/search.rb +77 -0
  46. data/lib/mihari/commands/securitytrails.rb +2 -2
  47. data/lib/mihari/commands/shodan.rb +1 -1
  48. data/lib/mihari/commands/spyse.rb +1 -1
  49. data/lib/mihari/commands/urlscan.rb +2 -2
  50. data/lib/mihari/commands/validator.rb +38 -0
  51. data/lib/mihari/commands/virustotal.rb +2 -2
  52. data/lib/mihari/commands/zoomeye.rb +1 -1
  53. data/lib/mihari/constraints.rb +5 -0
  54. data/lib/mihari/database.rb +13 -2
  55. data/lib/mihari/emitters/base.rb +2 -2
  56. data/lib/mihari/emitters/database.rb +1 -1
  57. data/lib/mihari/emitters/misp.rb +3 -1
  58. data/lib/mihari/emitters/slack.rb +6 -10
  59. data/lib/mihari/emitters/the_hive.rb +1 -1
  60. data/lib/mihari/emitters/webhook.rb +53 -0
  61. data/lib/mihari/mixins/configurable.rb +38 -0
  62. data/lib/mihari/mixins/configuration.rb +90 -0
  63. data/lib/mihari/mixins/hash.rb +20 -0
  64. data/lib/mihari/mixins/refang.rb +21 -0
  65. data/lib/mihari/mixins/retriable.rb +27 -0
  66. data/lib/mihari/mixins/rule.rb +79 -0
  67. data/lib/mihari/models/alert.rb +28 -1
  68. data/lib/mihari/models/artifact.rb +11 -1
  69. data/lib/mihari/notifiers/base.rb +9 -1
  70. data/lib/mihari/notifiers/exception_notifier.rb +50 -0
  71. data/lib/mihari/notifiers/slack.rb +29 -1
  72. data/lib/mihari/schemas/configuration.rb +42 -0
  73. data/lib/mihari/schemas/macros.rb +17 -0
  74. data/lib/mihari/schemas/rule.rb +72 -0
  75. data/lib/mihari/serializers/artifact.rb +1 -1
  76. data/lib/mihari/status.rb +14 -0
  77. data/lib/mihari/templates/rule.yml.erb +19 -0
  78. data/lib/mihari/type_checker.rb +8 -3
  79. data/lib/mihari/version.rb +1 -1
  80. data/lib/mihari/web/controllers/base_controller.rb +1 -1
  81. data/lib/mihari/web/public/index.html +1 -21
  82. data/lib/mihari/web/public/redoc-static.html +2 -2
  83. data/lib/mihari/web/public/static/js/app.ab213f7c.js +12 -0
  84. data/lib/mihari/web/public/static/js/app.ab213f7c.js.map +1 -0
  85. data/mihari.gemspec +19 -12
  86. metadata +138 -65
  87. data/.rubocop.yml +0 -161
  88. data/lib/mihari/analyzers/free_text.rb +0 -48
  89. data/lib/mihari/analyzers/http_hash.rb +0 -100
  90. data/lib/mihari/analyzers/passive_dns.rb +0 -59
  91. data/lib/mihari/analyzers/passive_ssl.rb +0 -55
  92. data/lib/mihari/analyzers/reverse_whois.rb +0 -55
  93. data/lib/mihari/analyzers/securitytrails_domain_feed.rb +0 -59
  94. data/lib/mihari/analyzers/ssh_fingerprint.rb +0 -58
  95. data/lib/mihari/cli.rb +0 -126
  96. data/lib/mihari/commands/config.rb +0 -27
  97. data/lib/mihari/commands/free_text.rb +0 -21
  98. data/lib/mihari/commands/http_hash.rb +0 -25
  99. data/lib/mihari/commands/passive_dns.rb +0 -21
  100. data/lib/mihari/commands/passive_ssl.rb +0 -21
  101. data/lib/mihari/commands/reverse_whois.rb +0 -21
  102. data/lib/mihari/commands/securitytrails_domain_feed.rb +0 -23
  103. data/lib/mihari/commands/ssh_fingerprint.rb +0 -21
  104. data/lib/mihari/config.rb +0 -83
  105. data/lib/mihari/configurable.rb +0 -21
  106. data/lib/mihari/html.rb +0 -43
  107. data/lib/mihari/retriable.rb +0 -17
  108. data/lib/mihari/slack_monkeypatch.rb +0 -16
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securitytrails"
4
-
5
- module Mihari
6
- module Analyzers
7
- class SecurityTrailsDomainFeed < Base
8
- attr_reader :type, :title, :description, :tags
9
-
10
- def initialize(regexp, type: "registered", title: nil, description: nil, tags: [])
11
- super()
12
-
13
- @_regexp = regexp
14
- @type = type
15
-
16
- raise InvalidInputError, "#{@_regexp} is not a valid regexp" unless regexp
17
- raise InvalidInputError, "#{type} is not a valid type" unless valid_type?
18
-
19
- @title = title || "SecurityTrails domain feed lookup"
20
- @description = description || "Regexp = /#{@_regexp}/"
21
- @tags = tags
22
- end
23
-
24
- def artifacts
25
- lookup || []
26
- end
27
-
28
- private
29
-
30
- def config_keys
31
- %w[securitytrails_api_key]
32
- end
33
-
34
- def api
35
- @api ||= ::SecurityTrails::API.new(Mihari.config.securitytrails_api_key)
36
- end
37
-
38
- def valid_type?
39
- %w[all new registered].include? type
40
- end
41
-
42
- def regexp
43
- @regexp ||= Regexp.compile(@_regexp)
44
- rescue InvalidInputError => _e
45
- nil
46
- end
47
-
48
- def lookup
49
- new_domains.select do |domain|
50
- regexp.match? domain
51
- end
52
- end
53
-
54
- def new_domains
55
- api.feeds.domains type
56
- end
57
- end
58
- end
59
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "parallel"
4
-
5
- module Mihari
6
- module Analyzers
7
- class SSHFingerprint < Base
8
- attr_reader :fingerprint, :title, :description, :tags
9
-
10
- def initialize(fingerprint, title: nil, description: nil, tags: [])
11
- super()
12
-
13
- @fingerprint = fingerprint
14
-
15
- @title = title || "SSH fingerprint cross search"
16
- @description = description || "fingerprint = #{fingerprint}"
17
- @tags = tags
18
- end
19
-
20
- def artifacts
21
- Parallel.map(analyzers) do |analyzer|
22
- run_analyzer analyzer
23
- end.flatten
24
- end
25
-
26
- private
27
-
28
- def valid_fingerprint?
29
- /^([0-9a-f]{2}:){15}[0-9a-f]{2}$/.match? fingerprint
30
- end
31
-
32
- def binary_edge
33
- BinaryEdge.new "ssh.fingerprint:\"#{fingerprint}\""
34
- end
35
-
36
- def shodan
37
- Shodan.new fingerprint
38
- end
39
-
40
- def analyzers
41
- raise InvalidInputError, "Invalid fingerprint is given." unless valid_fingerprint?
42
-
43
- [
44
- binary_edge,
45
- shodan
46
- ].compact
47
- end
48
-
49
- def run_analyzer(analyzer)
50
- analyzer.artifacts
51
- rescue ArgumentError, InvalidInputError => _e
52
- nil
53
- rescue ::BinaryEdge::Error, ::Shodan::Error => _e
54
- nil
55
- end
56
- end
57
- end
58
- end
data/lib/mihari/cli.rb DELETED
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "thor"
4
-
5
- require "mihari/commands/binaryedge"
6
- require "mihari/commands/censys"
7
- require "mihari/commands/circl"
8
- require "mihari/commands/crtsh"
9
- require "mihari/commands/dnpedia"
10
- require "mihari/commands/dnstwister"
11
- require "mihari/commands/onyphe"
12
- require "mihari/commands/otx"
13
- require "mihari/commands/passivetotal"
14
- require "mihari/commands/pulsedive"
15
- require "mihari/commands/securitytrails_domain_feed"
16
- require "mihari/commands/securitytrails"
17
- require "mihari/commands/shodan"
18
- require "mihari/commands/spyse"
19
- require "mihari/commands/urlscan"
20
- require "mihari/commands/virustotal"
21
- require "mihari/commands/zoomeye"
22
-
23
- require "mihari/commands/free_text"
24
- require "mihari/commands/http_hash"
25
- require "mihari/commands/passive_dns"
26
- require "mihari/commands/passive_ssl"
27
- require "mihari/commands/reverse_whois"
28
- require "mihari/commands/ssh_fingerprint"
29
-
30
- require "mihari/commands/config"
31
- require "mihari/commands/json"
32
- require "mihari/commands/web"
33
-
34
- module Mihari
35
- class CLI < Thor
36
- class_option :config, type: :string, desc: "Path to the config file"
37
-
38
- class_option :ignore_old_artifacts, type: :boolean, default: false, desc: "Whether to ignore old artifacts from checking or not. Only affects with analyze commands."
39
- class_option :ignore_threshold, type: :numeric, default: 0, desc: "Number of days to define whether an artifact is old or not. Only affects with analyze commands."
40
-
41
- include Mihari::Commands::BinaryEdge
42
- include Mihari::Commands::Censys
43
- include Mihari::Commands::CIRCL
44
- include Mihari::Commands::Config
45
- include Mihari::Commands::Crtsh
46
- include Mihari::Commands::DNPedia
47
- include Mihari::Commands::DNSTwister
48
- include Mihari::Commands::FreeText
49
- include Mihari::Commands::HTTPHash
50
- include Mihari::Commands::JSON
51
- include Mihari::Commands::Onyphe
52
- include Mihari::Commands::OTX
53
- include Mihari::Commands::PassiveDNS
54
- include Mihari::Commands::PassiveSSL
55
- include Mihari::Commands::PassiveTotal
56
- include Mihari::Commands::Pulsedive
57
- include Mihari::Commands::ReverseWhois
58
- include Mihari::Commands::SecurityTrails
59
- include Mihari::Commands::SecurityTrailsDomainFeed
60
- include Mihari::Commands::Shodan
61
- include Mihari::Commands::Spyse
62
- include Mihari::Commands::SSHFingerprint
63
- include Mihari::Commands::Urlscan
64
- include Mihari::Commands::VirusTotal
65
- include Mihari::Commands::Web
66
- include Mihari::Commands::ZoomEye
67
-
68
- class << self
69
- def exit_on_failure?
70
- true
71
- end
72
- end
73
-
74
- no_commands do
75
- def with_error_handling
76
- yield
77
- rescue StandardError => e
78
- notifier = Notifiers::ExceptionNotifier.new
79
- notifier.notify e
80
- end
81
-
82
- # @return [true, false]
83
- def valid_json?(json)
84
- %w[title description artifacts].all? { |key| json.key? key }
85
- end
86
-
87
- def load_configuration
88
- config = options["config"]
89
- return unless config
90
-
91
- Config.load_from_yaml(config)
92
- Database.connect
93
- end
94
-
95
- def run_analyzer(analyzer_class, query:, options:)
96
- load_configuration
97
-
98
- options = symbolize_hash_keys(options)
99
- options = normalize_options(options)
100
-
101
- analyzer = analyzer_class.new(query, **options)
102
-
103
- analyzer.ignore_old_artifacts = options[:ignore_old_artifacts] || false
104
- analyzer.ignore_threshold = options[:ignore_threshold] || 0
105
-
106
- analyzer.run
107
- end
108
-
109
- def symbolize_hash_keys(hash)
110
- hash.transform_keys(&:to_sym)
111
- end
112
-
113
- def normalize_options(options)
114
- # Delete :config because it is not intended to use for running an analyzer
115
- [:config, :ignore_old_artifacts, :ignore_threshold].each do |ignore_key|
116
- options.delete(ignore_key)
117
- end
118
- options
119
- end
120
-
121
- def refang(indicator)
122
- indicator.gsub("[.]", ".").gsub("(.)", ".")
123
- end
124
- end
125
- end
126
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "colorize"
4
-
5
- module Mihari
6
- module Commands
7
- module Config
8
- def self.included(thor)
9
- thor.class_eval do
10
- desc "init_config", "Create a config file"
11
- method_option :filename, type: :string, default: "mihari.yml"
12
- def init_config
13
- filename = options["filename"]
14
-
15
- warning = "#{filename} exists. Do you want to overwrite it? (y/n)"
16
- if File.exist?(filename) && !(yes? warning)
17
- return
18
- end
19
-
20
- Mihari::Config.initialize_yaml filename
21
- puts "The config file is initialized as #{filename}.".colorize(:blue)
22
- end
23
- end
24
- end
25
- end
26
- end
27
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module FreeText
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "free_text [TEXT]", "Cross search with search engines by a free text"
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 free_text(text)
13
- with_error_handling do
14
- run_analyzer Analyzers::FreeText, query: text, options: options
15
- end
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module HTTPHash
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "http_hash", "Cross search with search engines by a hash of an HTTP response (SHA256, MD5 and MurmurHash3)"
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
- method_option :md5, type: :string, desc: "MD5 hash"
13
- method_option :sha256, type: :string, desc: "SHA256 hash"
14
- method_option :mmh3, type: :numeric, desc: "MurmurHash3 hash"
15
- method_option :html, type: :string, desc: "path to an HTML file"
16
- def http_hash
17
- with_error_handling do
18
- run_analyzer Analyzers::HTTPHash, query: nil, options: options
19
- end
20
- end
21
- end
22
- end
23
- end
24
- end
25
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module PassiveDNS
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "passive_dns [IP|DOMAIN]", "Cross search with passive DNS services by an ip or domain"
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 passive_dns(query)
13
- with_error_handling do
14
- run_analyzer Analyzers::PassiveDNS, query: refang(query), options: options
15
- end
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module PassiveSSL
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "passive_ssl [SHA1]", "Cross search with passive SSL services by an SHA1 certificate fingerprint"
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 passive_ssl(query)
13
- with_error_handling do
14
- run_analyzer Analyzers::PassiveSSL, query: query, options: options
15
- end
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module ReverseWhois
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "reverse_whois [EMAIL]", "Cross search with reverse whois services by an email"
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 reverse_whois(query)
13
- with_error_handling do
14
- run_analyzer Analyzers::ReveseWhois, query: refang(query), options: options
15
- end
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module SecurityTrailsDomainFeed
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "securitytrails_domain_feed [REGEXP]", "SecurityTrails new domain feed search by a regexp"
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
- method_option :type, type: :string, default: "registered", desc: "A type of domain feed ('all', 'new' or 'registered')"
13
- def securitytrails_domain_feed(regexp)
14
- with_error_handling do
15
- run_analyzer Analyzers::SecurityTrailsDomainFeed, query: regexp, options: options
16
- end
17
- end
18
- map "st_domain_feed" => :securitytrails_domain_feedd
19
- end
20
- end
21
- end
22
- end
23
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mihari
4
- module Commands
5
- module SSHFingerprint
6
- def self.included(thor)
7
- thor.class_eval do
8
- desc "ssh_fingerprint [FINGERPRINT]", "Cross search with search engines by an SSH fingerprint (e.g. dc:14:de:8e:d7:c1:15:43:23:82:25:81:d2:59:e8:c0)"
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 ssh_fingerprint(fingerprint)
13
- with_error_handling do
14
- run_analyzer Analyzers::SSHFingerprint, query: fingerprint, options: options
15
- end
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
data/lib/mihari/config.rb DELETED
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Mihari
6
- class Config
7
- attr_accessor :binaryedge_api_key, :censys_id, :censys_secret, :circl_passive_password, :circl_passive_username, :misp_api_endpoint, :misp_api_key, :onyphe_api_key, :otx_api_key, :passivetotal_api_key, :passivetotal_username, :pulsedive_api_key, :securitytrails_api_key, :shodan_api_key, :slack_channel, :slack_webhook_url, :spyse_api_key, :thehive_api_endpoint, :thehive_api_key, :urlscan_api_key, :virustotal_api_key, :zoomeye_api_key, :database
8
-
9
- def initialize
10
- load_from_env
11
- end
12
-
13
- def load_from_env
14
- @binaryedge_api_key = ENV["BINARYEDGE_API_KEY"]
15
- @censys_id = ENV["CENSYS_ID"]
16
- @censys_secret = ENV["CENSYS_SECRET"]
17
- @circl_passive_password = ENV["CIRCL_PASSIVE_PASSWORD"]
18
- @circl_passive_username = ENV["CIRCL_PASSIVE_USERNAME"]
19
- @misp_api_endpoint = ENV["MISP_API_ENDPOINT"]
20
- @misp_api_key = ENV["MISP_API_KEY"]
21
- @onyphe_api_key = ENV["ONYPHE_API_KEY"]
22
- @otx_api_key = ENV["OTX_API_KEY"]
23
- @passivetotal_api_key = ENV["PASSIVETOTAL_API_KEY"]
24
- @passivetotal_username = ENV["PASSIVETOTAL_USERNAME"]
25
- @pulsedive_api_key = ENV["PULSEDIVE_API_KEY"]
26
- @securitytrails_api_key = ENV["SECURITYTRAILS_API_KEY"]
27
- @shodan_api_key = ENV["SHODAN_API_KEY"]
28
- @slack_channel = ENV["SLACK_CHANNEL"]
29
- @slack_webhook_url = ENV["SLACK_WEBHOOK_URL"]
30
- @spyse_api_key = ENV["SPYSE_API_KEY"]
31
- @thehive_api_endpoint = ENV["THEHIVE_API_ENDPOINT"]
32
- @thehive_api_key = ENV["THEHIVE_API_KEY"]
33
- @urlscan_api_key = ENV["URLSCAN_API_KEY"]
34
- @virustotal_api_key = ENV["VIRUSTOTAL_API_KEY"]
35
- @zoomeye_api_key = ENV["ZOOMEYE_API_KEY"]
36
-
37
- @database = ENV["DATABASE"] || "mihari.db"
38
- end
39
-
40
- class << self
41
- def load_from_yaml(path)
42
- raise ArgumentError, "#{path} does not exist." unless File.exist?(path)
43
-
44
- data = File.read(path)
45
- begin
46
- yaml = YAML.safe_load(data)
47
- rescue TypeError => _e
48
- return
49
- end
50
-
51
- Mihari.configure do |config|
52
- yaml.each do |key, value|
53
- config.send("#{key.downcase}=".to_sym, value)
54
- end
55
- end
56
- end
57
-
58
- def initialize_yaml(filename)
59
- keys = new.instance_variables.map do |key|
60
- key.to_s[1..]
61
- end
62
-
63
- config = keys.map do |key|
64
- [key, nil]
65
- end.to_h
66
-
67
- YAML.dump(config, File.open(filename, "w"))
68
- end
69
- end
70
- end
71
-
72
- class << self
73
- def config
74
- @config ||= Config.new
75
- end
76
-
77
- attr_writer :config
78
-
79
- def configure
80
- yield config
81
- end
82
- end
83
- end