spektr 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yaml +32 -0
  3. data/.gitignore +9 -0
  4. data/.travis.yml +6 -0
  5. data/CHANGELOG.md +3 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +4 -0
  8. data/Gemfile.lock +134 -0
  9. data/Guardfile +45 -0
  10. data/LICENSE.txt +27 -0
  11. data/README.md +70 -0
  12. data/Rakefile +10 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/bin/spektr +7 -0
  16. data/lib/spektr/app.rb +209 -0
  17. data/lib/spektr/checks/base.rb +151 -0
  18. data/lib/spektr/checks/basic_auth.rb +27 -0
  19. data/lib/spektr/checks/basic_auth_timing.rb +24 -0
  20. data/lib/spektr/checks/command_injection.rb +48 -0
  21. data/lib/spektr/checks/content_tag_xss.rb +54 -0
  22. data/lib/spektr/checks/cookie_serialization.rb +21 -0
  23. data/lib/spektr/checks/create_with.rb +27 -0
  24. data/lib/spektr/checks/csrf.rb +25 -0
  25. data/lib/spektr/checks/csrf_setting.rb +39 -0
  26. data/lib/spektr/checks/default_routes.rb +43 -0
  27. data/lib/spektr/checks/deserialize.rb +62 -0
  28. data/lib/spektr/checks/detailed_exceptions.rb +29 -0
  29. data/lib/spektr/checks/digest_dos.rb +28 -0
  30. data/lib/spektr/checks/dynamic_finders.rb +26 -0
  31. data/lib/spektr/checks/evaluation.rb +25 -0
  32. data/lib/spektr/checks/file_access.rb +38 -0
  33. data/lib/spektr/checks/file_disclosure.rb +25 -0
  34. data/lib/spektr/checks/filter_skipping.rb +29 -0
  35. data/lib/spektr/checks/header_dos.rb +20 -0
  36. data/lib/spektr/checks/i18n_xss.rb +20 -0
  37. data/lib/spektr/checks/json_encoding.rb +23 -0
  38. data/lib/spektr/checks/json_entity_escape.rb +30 -0
  39. data/lib/spektr/checks/json_parsing.rb +47 -0
  40. data/lib/spektr/checks/link_to_href.rb +35 -0
  41. data/lib/spektr/checks/mass_assignment.rb +42 -0
  42. data/lib/spektr/checks/send.rb +24 -0
  43. data/lib/spektr/checks/sqli.rb +52 -0
  44. data/lib/spektr/checks/xss.rb +49 -0
  45. data/lib/spektr/checks.rb +9 -0
  46. data/lib/spektr/cli.rb +53 -0
  47. data/lib/spektr/erubi.rb +78 -0
  48. data/lib/spektr/exp/assignment.rb +20 -0
  49. data/lib/spektr/exp/base.rb +32 -0
  50. data/lib/spektr/exp/const.rb +7 -0
  51. data/lib/spektr/exp/definition.rb +32 -0
  52. data/lib/spektr/exp/ivasign.rb +7 -0
  53. data/lib/spektr/exp/lvasign.rb +7 -0
  54. data/lib/spektr/exp/send.rb +135 -0
  55. data/lib/spektr/exp/xstr.rb +12 -0
  56. data/lib/spektr/processors/base.rb +80 -0
  57. data/lib/spektr/processors/class_processor.rb +25 -0
  58. data/lib/spektr/targets/base.rb +119 -0
  59. data/lib/spektr/targets/config.rb +6 -0
  60. data/lib/spektr/targets/controller.rb +74 -0
  61. data/lib/spektr/targets/model.rb +6 -0
  62. data/lib/spektr/targets/routes.rb +38 -0
  63. data/lib/spektr/targets/view.rb +34 -0
  64. data/lib/spektr/version.rb +3 -0
  65. data/lib/spektr/warning.rb +23 -0
  66. data/lib/spektr.rb +120 -0
  67. data/spektr.gemspec +49 -0
  68. metadata +362 -0
@@ -0,0 +1,135 @@
1
+ module Spektr
2
+ module Exp
3
+ class Send < Base
4
+ attr_accessor :receiver
5
+
6
+ def initialize(ast)
7
+ super
8
+ @receiver = Receiver.new(ast.children[0]) if ast.children[0]
9
+ @name = if ast.children.first.is_a?(Parser::AST::Node)
10
+ ast.children.first.children.last
11
+ else
12
+ ast.children[1]
13
+ end
14
+ children = ast.children[2..]
15
+ children.each do |child|
16
+ next unless child.is_a?(Parser::AST::Node)
17
+
18
+ case child.type
19
+ when :hash
20
+ if children.size == 1 || children.last == child
21
+ child.children.each do |pair|
22
+ @options[pair.children[0].children[0]] = Option.new(pair)
23
+ end
24
+ else
25
+ @arguments << Argument.new(child)
26
+ end
27
+ else
28
+ @arguments << Argument.new(child)
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class Argument < Base
35
+ attr_accessor :name, :type, :ast, :children
36
+
37
+ def initialize(ast)
38
+ @name = nil
39
+ process_ast(ast)
40
+ ast = ast.children.first if ast.type == :begin
41
+ @ast = ast
42
+ argument = if %i[xstr hash].include? ast.type
43
+ ast
44
+ elsif ast.type != :dstr && ast.children.first.is_a?(Parser::AST::Node) && ast.children.first.children.first.is_a?(Parser::AST::Node)
45
+ ast.children.first.children.first
46
+ elsif ast.type != :dstr && ast.children.first.is_a?(Parser::AST::Node)
47
+ ast.children.first
48
+ else
49
+ ast
50
+ end
51
+ @type = argument.type
52
+ @children = argument.children
53
+ end
54
+
55
+ def process_ast(ast)
56
+ process(ast)
57
+ end
58
+
59
+ def on_begin(node)
60
+ process_all(node)
61
+ end
62
+
63
+ def on_send(node)
64
+ if node.children.first.nil?
65
+ @name ||= node.children[1]
66
+ elsif node.is_a?(Parser::AST::Node)
67
+ process_all(node)
68
+ end
69
+ end
70
+
71
+ def on_const(node)
72
+ @name ||= node.children[1] if node.children.first.nil?
73
+ end
74
+
75
+ def on_str(node)
76
+ @name ||= node.children.first
77
+ end
78
+
79
+ alias on_sym on_str
80
+ alias on_ivar on_str
81
+ end
82
+
83
+ class Option
84
+ attr_accessor :name, :key, :value, :type, :value_name, :value_type
85
+
86
+ def initialize(ast)
87
+ @key = ast.children.first
88
+ @name = ast.children.first.children.last
89
+ @type = ast.type
90
+
91
+ @value = ast.children.last
92
+ @value_name = ast.children.last.children.last
93
+ @value_type = ast.children.last.type
94
+ end
95
+ end
96
+
97
+ class Receiver
98
+ attr_accessor :name, :type, :ast, :expanded, :children
99
+
100
+ def initialize(ast)
101
+ @children = []
102
+ set_attributes(ast)
103
+ end
104
+
105
+ def set_attributes(ast)
106
+ if [:begin].include?(ast.type) && ast.children[0].is_a?(Parser::AST::Node)
107
+ return set_attributes(ast.children[0])
108
+ end
109
+
110
+ @ast = ast
111
+ if ast.type == :dstr
112
+ @type = :dstr
113
+ @name = ast.children[0].children.first
114
+ ast.children[1..].each do |ch|
115
+ @children << Receiver.new(ch)
116
+ end
117
+ else
118
+ @expanded = expand!(ast)
119
+ @ast = ast.type == :send && ast.children[0].is_a?(Parser::AST::Node) ? ast.children[0] : ast
120
+ @type = @ast.type
121
+ @name = @ast.children.last
122
+ end
123
+ end
124
+
125
+ def expand!(ast, tree = [])
126
+ if ast.is_a?(Parser::AST::Node) && ast.children.any?
127
+ tree << ast.children.last
128
+ expand!(ast.children.first, tree)
129
+ else
130
+ tree.reverse.join('.')
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,12 @@
1
+ module Spektr
2
+ module Exp
3
+ class Xstr < Base
4
+ def initialize(ast)
5
+ super
6
+ ast.children[1..].each do |child|
7
+ @arguments << Argument.new(child)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,80 @@
1
+ require 'ast'
2
+ module Spektr::Processors
3
+ class Base
4
+ include AST::Processor::Mixin
5
+ attr_accessor :name, :name_parts, :parent_parts, :parent_modules, :parent_name, :parent_name_with_modules
6
+
7
+ def initialize
8
+ @modules = []
9
+ @name_parts = []
10
+ @parent_parts = []
11
+ @parent_modules = []
12
+ end
13
+
14
+ def name
15
+ @name_parts.join('::')
16
+ end
17
+
18
+ def parent_name
19
+ @parent_parts.shift if @parent_parts.first.to_s == name
20
+ @parent_parts.join('::')
21
+ end
22
+
23
+ def parent_name_with_modules
24
+ parts = @parent_modules | @parent_parts
25
+ parts.shift if parts.first.to_s == name
26
+ parts.join('::')
27
+ end
28
+
29
+ def on_begin(node)
30
+ process_all(node)
31
+ end
32
+
33
+ def on_module(node)
34
+ parts = extract_name_part(node)
35
+ @modules.concat(parts)
36
+ @name_parts.concat(parts)
37
+ @parent_modules << node.children.first.children.last
38
+ process_all(node)
39
+ end
40
+
41
+ def extract_parent_parts(node)
42
+ if node.children[1] && node.children[1].is_a?(Parser::AST::Node)
43
+ node.children[1].children.each do |child|
44
+ if child.is_a?(Parser::AST::Node)
45
+ extract_parent_parts(child)
46
+ @parent_parts << child.children.last
47
+ elsif child.is_a? Symbol
48
+ @parent_parts << child.to_s
49
+ end
50
+ end
51
+ elsif node&.children&.first&.children&.last
52
+ @parent_parts << node.children.first.children.last
53
+ end
54
+ end
55
+
56
+ def on_class(node)
57
+ extract_parent_parts(node)
58
+ @name_parts.concat(extract_name_part(node))
59
+ process_all(node)
60
+ end
61
+
62
+ def extract_name_part(node)
63
+ parts = []
64
+ node.children.first.children.each do |child|
65
+ if child.is_a?(Parser::AST::Node)
66
+ parts << child.children.last
67
+ elsif child.is_a? Symbol
68
+ parts << child.to_s
69
+ end
70
+ end
71
+ parts
72
+ end
73
+
74
+ def on_const(node); end
75
+
76
+ def handler_missing(node)
77
+ # puts "handler missing for #{node.type}"
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,25 @@
1
+ module Spektr
2
+ module Processors
3
+ class ClassProcessor < Base
4
+ include AST::Processor::Mixin
5
+
6
+ attr_accessor :data
7
+
8
+ def on_begin(node)
9
+ end
10
+
11
+ def on_def(node)
12
+ puts "on def: #{node.inspect}"
13
+ end
14
+
15
+ def on_require(node)
16
+ puts "on require: #{node.inspect}"
17
+ end
18
+
19
+ def on_class(node)
20
+ debugger
21
+ puts "on class2: #{node.inspect}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,119 @@
1
+ module Spektr
2
+ module Targets
3
+ class Base
4
+ attr_accessor :path, :name, :options, :ast, :parent, :processor
5
+
6
+ def initialize(path, content)
7
+ Spektr.logger.debug "loading #{path}"
8
+ @ast = Spektr::App.parser.parse(content)
9
+ @path = path
10
+ return unless @ast
11
+
12
+ @processor = Spektr::Processors::Base.new
13
+ @processor.process(@ast)
14
+ @name = @processor.name
15
+ @name = @path.split('/').last if @name.blank?
16
+
17
+ @current_method_type = :public
18
+ @parent = @processor.parent_name
19
+ end
20
+
21
+ def find_calls(name, receiver = nil)
22
+ calls = find(:send, name, @ast).map { |ast| Exp::Send.new(ast) }
23
+ if receiver
24
+ calls.select! { |call| call.receiver&.expanded == receiver }
25
+ elsif receiver == false
26
+ calls.select! { |call| call.receiver.nil? }
27
+ end
28
+ calls
29
+ end
30
+
31
+ def find_calls_with_block(name, _receiver = nil)
32
+ blocks = find(:block, nil, @ast)
33
+ blocks.each_with_object([]) do |block, memo|
34
+ if block.children.first.children[1] == name
35
+ result = find(:send, name, block).map { |ast| Exp::Send.new(ast) }
36
+ memo << result.first
37
+ end
38
+ end
39
+ end
40
+
41
+ def find_method(name)
42
+ find(:def, name, @ast).last
43
+ end
44
+
45
+ def find_xstr
46
+ find(:xstr, nil, @ast).map { |ast| Exp::Xstr.new(ast) }
47
+ end
48
+
49
+ def find(type, name, ast, result = [])
50
+ return result unless ast.is_a? Parser::AST::Node
51
+
52
+ name_index = case type
53
+ when :def
54
+ 0
55
+ else
56
+ 1
57
+ end
58
+ if node_matches?(ast.type, ast.children[name_index], type, name)
59
+ result << ast
60
+ elsif ast.children.any?
61
+ ast.children.each do |child|
62
+ result = find(type, name, child, result)
63
+ end
64
+ end
65
+ result
66
+ end
67
+
68
+ def node_matches?(node_type, node_name, type, name)
69
+ if node_type == type
70
+ if name.is_a? Regexp
71
+ return node_name =~ name
72
+ elsif name.nil?
73
+ return true
74
+ else
75
+ return node_name == name
76
+ end
77
+ end
78
+ false
79
+ end
80
+
81
+ def find_methods(ast:, result: [], type: :all)
82
+ return result unless ast.is_a?(Parser::AST::Node)
83
+
84
+ if ast.type == :send && %i[private public protected].include?(ast.children.last)
85
+ @current_method_type = ast.children.last
86
+ end
87
+ if ast.type == :def && [:all, @current_method_type].include?(type)
88
+ result << ast
89
+ elsif ast.children.any?
90
+ ast.children.map do |child|
91
+ result = find_methods(ast: child, result: result, type: type)
92
+ end
93
+ end
94
+ result
95
+ end
96
+
97
+ def ast_to_exp(ast)
98
+ case ast.type
99
+ when :send
100
+ Exp::Send.new(ast)
101
+ when :def
102
+ Exp::Definition.new(ast)
103
+ when :ivasgn
104
+ Exp::Ivasgin.new(ast)
105
+ when :lvasign
106
+ Exp::Lvasign.new(ast)
107
+ when :const
108
+ Exp::Const.new(ast)
109
+ when :xstr
110
+ Exp::Xstr.new(ast)
111
+ when :sym, :int, :str
112
+ Exp::Base.new(ast)
113
+ else
114
+ raise "Unknown type #{ast.type} #{ast.inspect}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,6 @@
1
+ module Spektr
2
+ module Targets
3
+ class Config < Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,74 @@
1
+ module Spektr
2
+ module Targets
3
+ class Controller < Base
4
+ attr_accessor :actions
5
+
6
+ def initialize(path, content)
7
+ super
8
+ find_actions
9
+ end
10
+
11
+ def concern?
12
+ !name.match('Controller')
13
+ end
14
+
15
+ def find_actions
16
+ @actions = find_methods(ast: @ast, type: :public).map do |ast|
17
+ Action.new(ast, self)
18
+ end
19
+ end
20
+
21
+ def find_parent(controllers)
22
+ parent_name = @processor.parent_name
23
+ result = find_in_set(parent_name, controllers)
24
+ result ||= find_in_set(processor.parent_name_with_modules, controllers)
25
+ return nil if result&.name == name
26
+
27
+ result
28
+ end
29
+
30
+ def find_in_set(name, set)
31
+ while true
32
+ result = set.find { |c| c.name == name }
33
+ break if result
34
+
35
+ split = name.split('::')
36
+ split.shift
37
+ name = split.join('::')
38
+ break if name.blank?
39
+ end
40
+ result
41
+ end
42
+
43
+ class Action < Spektr::Exp::Definition
44
+ attr_accessor :controller, :template
45
+
46
+ def initialize(ast, controller)
47
+ super(ast)
48
+ @template = nil
49
+ split = []
50
+ if controller.parent
51
+ split = controller.parent.split('::').map { |e| e.delete_suffix('Controller') }.map(&:downcase)
52
+ if split.size > 1
53
+ split.pop
54
+ @template = "#{split.join('/')}/#{@template}"
55
+ end
56
+ end
57
+ split = split.concat(controller.name.delete_suffix('Controller').split('::').map(&:downcase)).uniq
58
+ split.delete('application')
59
+ @template = File.join(*split, name.to_s)
60
+ @body.each do |exp|
61
+ if exp.send? && (exp.name == :render && exp.arguments.any?)
62
+ if exp.arguments.first.type == :sym
63
+ @template = File.join(controller.name.delete_suffix('Controller').underscore,
64
+ exp.arguments.first.name.to_s)
65
+ elsif exp.arguments.first.type == :str
66
+ @template = exp.arguments.first.name
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,6 @@
1
+ module Spektr
2
+ module Targets
3
+ class Model < Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ module Spektr
2
+ module Targets
3
+ class Routes < Base
4
+ attr_accessor :routes
5
+
6
+ def initialize(path, content)
7
+ super
8
+ end
9
+
10
+ def find_actions
11
+ @actions = find_methods(ast: @ast, type: :public ).map do |ast|
12
+ Action.new(ast, self)
13
+ end
14
+ end
15
+
16
+ class Action < Spektr::Exp::Definition
17
+ attr_accessor :controller, :template
18
+ def initialize(ast, controller)
19
+ super(ast)
20
+ @template = File.join(controller.name.delete_suffix("Controller").underscore, name.to_s)
21
+ @body.each do |exp|
22
+ if exp.send?
23
+ if exp.name == :render
24
+ if exp.arguments.first.type == :sym
25
+ @template = File.join(controller.name.delete_suffix("Controller").underscore, exp.arguments.first.name.to_s)
26
+ elsif
27
+ if exp.arguments.first.type == :str
28
+ @template = exp.arguments.first.name
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ module Spektr
2
+ module Targets
3
+ class View < Base
4
+ TEMPLATE_EXTENSIONS = /.*\.(erb|rhtml|haml)$/
5
+ attr_accessor :view_path
6
+
7
+ def initialize(path, content)
8
+ Spektr.logger.debug "loading #{path}"
9
+ @view_path = nil
10
+ @path = path
11
+ if match_data = path.match(%r{views/(.+?)\.})
12
+ @view_path = match_data[1]
13
+ end
14
+ begin
15
+ @ast = Spektr::App.parser.parse(source(content))
16
+ rescue Parser::SyntaxError => e
17
+ @ast = Spektr::App.parser.parse('')
18
+ ::Spektr.logger.error "Parser::SyntaxError when parsing #{@view_path}: #{e.message}"
19
+ end
20
+ @name = @view_path # @ast.children.first.children.last.to_s
21
+ end
22
+
23
+ def source(content)
24
+ type = @path.match(TEMPLATE_EXTENSIONS)[1].to_sym
25
+ case type
26
+ when :erb, :rhtml
27
+ Erubi.new(content, trim_mode: '-').src
28
+ when :haml
29
+ Haml::Engine.new(content).precompiled
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module Spektr
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,23 @@
1
+ module Spektr
2
+ class Warning
3
+ attr_accessor :path, :full_path, :check, :location, :message, :confidence, :line
4
+ def initialize(path, full_path, check, location, message, confidence = :high)
5
+ @path = path
6
+ @check = check
7
+ @location = location
8
+ @message = message
9
+ @confidence = confidence
10
+ if full_path && @location && File.exist?(full_path)
11
+ @line = IO.readlines(full_path)[@location.line - 1].strip
12
+ end
13
+ end
14
+
15
+ def full_message
16
+ if @location
17
+ "#{message} at line #{@location.line} of #{@path}"
18
+ else
19
+ "#{message}"
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/spektr.rb ADDED
@@ -0,0 +1,120 @@
1
+ require 'spektr/version'
2
+ require 'bundler'
3
+ require 'parser'
4
+ require 'parser/current'
5
+ require 'unparser'
6
+ require 'erb'
7
+ require 'haml'
8
+ require 'active_support/core_ext/string/inflections'
9
+ require 'logger'
10
+ require 'tty/spinner'
11
+ require 'tty/table'
12
+
13
+ require 'zeitwerk'
14
+ loader = Zeitwerk::Loader.for_gem
15
+ loader.collapse("#{__dir__}/processors")
16
+ loader.setup # ready!
17
+ loader.eager_load
18
+
19
+ module Spektr
20
+ class Error < StandardError; end
21
+
22
+ def self.run(root = nil, output_format = 'terminal', debug = false, checks = nil)
23
+ pastel = Pastel.new
24
+ @output_format = output_format
25
+ start_spinner('Initializing')
26
+ @log_level = debug ? Logger::DEBUG : Logger::WARN
27
+ checks = Checks.load(checks)
28
+ root = './' if root.nil?
29
+ @app = App.new(checks: checks, root: root)
30
+ stop_spinner
31
+ puts "\n"
32
+ puts pastel.bold('Checks:')
33
+ puts "\n"
34
+ puts checks.collect(&:name).join(', ')
35
+ # table = TTY::Table.new([['Checks', checks.collect(&:name).join(', ')]])
36
+ # puts table.render(:basic)
37
+ puts "\n"
38
+
39
+ start_spinner('Loading files')
40
+ @app.load
41
+ stop_spinner
42
+ puts "\n"
43
+ table = TTY::Table.new([
44
+ ['Rails version', @app.rails_version],
45
+ ['Initializers', @app.initializers.size],
46
+ ['Controllers', @app.controllers.size],
47
+ ['Models', @app.models.size],
48
+ ['Views', @app.views.size],
49
+ ['Routes', @app.routes.size],
50
+ ['Lib files', @app.lib_files.size]
51
+ ])
52
+ puts table.render(:basic)
53
+ puts "\n"
54
+ start_spinner('Scanning files')
55
+ @app.scan!
56
+ stop_spinner
57
+ puts "\n"
58
+ json = @app.report
59
+ case output_format
60
+ when 'json'
61
+ json
62
+ when 'terminal'
63
+ puts pastel.bold("Advisories\n")
64
+
65
+ json[:advisories].each do |advisory|
66
+ puts "#{pastel.green('Name:')} #{advisory[:name]}\n"
67
+ puts "#{pastel.green('Check:')} #{advisory[:check]}\n"
68
+ puts "#{pastel.green('Description:')} #{advisory[:description]}\n"
69
+ puts "#{pastel.green('Path:')} #{advisory[:path]}\n"
70
+ puts "#{pastel.green('Location:')} #{advisory[:location]}\n"
71
+ puts "#{pastel.green('Code:')} #{advisory[:line]}\n"
72
+ puts "\n"
73
+ puts "\n"
74
+ end
75
+
76
+ puts pastel.bold("Summary\n")
77
+ summary = []
78
+ json[:advisories].group_by { |a| a[:name] }.each do |n, i|
79
+ summary << [pastel.green(n), i.size]
80
+ end
81
+
82
+ table = TTY::Table.new(summary, padding: [2, 2, 2, 2])
83
+ puts table.render(:basic)
84
+ puts "\n\n"
85
+ exit 1 if json[:advisories].any?
86
+ else
87
+ puts 'Unknown format'
88
+ end
89
+ end
90
+
91
+ def self.terminal?
92
+ @output_format == 'terminal'
93
+ end
94
+
95
+ def self.start_spinner(label)
96
+ return unless terminal?
97
+
98
+ @spinner = TTY::Spinner.new("[:spinner] #{label}", format: :classic)
99
+ @spinner.auto_spin
100
+ end
101
+
102
+ def self.stop_spinner
103
+ return unless terminal?
104
+
105
+ @spinner&.stop('Done!')
106
+ end
107
+
108
+ def self.swap_spinner(label)
109
+ stop_spinner
110
+ start_spinner(label)
111
+ end
112
+
113
+ def self.logger
114
+ @logger ||= begin
115
+ logger = Logger.new(STDOUT)
116
+ logger.level = @log_level || Logger::WARN
117
+ logger
118
+ end
119
+ end
120
+ end