timing_attack 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d03f591c320a5983d1a95d594e0ac067f79f9019
4
- data.tar.gz: 587b251ddbe7ee8698fb9f1f6309ce2c0934b915
3
+ metadata.gz: 5b6f971fe9e7eb6b8aa282dffbd7c0eea3a2f009
4
+ data.tar.gz: e05deb06b3c31be39f128c7906ef3534abad6d09
5
5
  SHA512:
6
- metadata.gz: 599c51ebac9e5b3ca49fec6d3364682427de3d6c85eda144b981c7138393cb658b2a88ac11f5a1eecef7b768569c3eb212a151634a34012758af18267fa05a26
7
- data.tar.gz: c61b9cf40bcb33a9beed350d1f39b79a7f93c67430100c9fb021ca6bd23a72defc7a8814b7e7e5e78db61b2bbefe74ea02d3a5fa3a8eaf40e54433b6ba4e39bd
6
+ metadata.gz: c0e1f43f47fafd477678072023fcba97c6db2dbe12f6d09f6c1face92feed5fe6a17f2a16e79bb692e0db1b4e912f7136f5cd47d3f07e66dabafbf52ab293df3
7
+ data.tar.gz: 8977b5ddbea3da6e3511c1e9b0c9921d194a99e1cfabe7cbe104882579a949a562458fcc17adf72ded2ddb8cbc08e51cf4950349fe30046f9e342f85d5743e73
data/README.md CHANGED
@@ -12,24 +12,21 @@ discrepancies in the application's response time.
12
12
  ## Usage
13
13
 
14
14
  ```
15
- timing_attack [options] -a <input> -b <input> -u <target> <inputs>
15
+ timing_attack [options] -u <target> <inputs>
16
16
  -u, --url URL URL of endpoint to profile
17
- -a, --a-example A_EXAMPLE Known test case that belongs to Group A
18
- -b, --b-example B_EXAMPLE Known test case that belongs to Group B
19
17
  -n, --number NUM Requests per input
20
- --a-name A_NAME Name of Group A
21
- --b-name B_NAME Name of Group B
18
+ -t, --threshold NUM Minimum threshold, in seconds, for meaningfulness (default: 0.05)
22
19
  -p, --post Use POST, not GET
23
20
  -q, --quiet Quiet mode (don't display progress bars)
21
+ --mean Use mean for calculations
22
+ --median Use median for calculations
23
+ --percentile N Use Nth percentile for calculations
24
24
  -v, --version Print version information
25
25
  -h, --help Display this screen
26
26
  ```
27
27
 
28
- **NB**: If the provided examples are invalid, discvery will fail. Always check
29
- your results! If very similar inputs are being sorted differently, you may have
30
- used bad training data.
31
-
32
28
  ### An example
29
+
33
30
  Consider that we we want to gather information from a Rails server running
34
31
  locally at `http://localhost:3000`. Let's say that we know the following:
35
32
  * `charles@poodles.com` exists in the database
@@ -41,34 +38,39 @@ the database.
41
38
  We execute (using `-q` to suppress the progress bar)
42
39
  ```bash
43
40
  % timing_attack -q -u http://localhost:3000/login \
44
- -a charles@poodles.com -b invalid@fake.fake \
45
- candidate@address.com other@address.com
41
+ candidate@address.com other@address.com \
42
+ charles@poodles.com invalid@fake.fake
46
43
  ```
47
44
  ```
48
- Group A:
49
- candidate@address.com ~0.1969s
50
- Group B:
51
- other@address.com ~0.1096s
45
+ Short tests:
46
+ other@address.com 0.0926
47
+ invalid@fake.fake 0.0947
48
+ Long tests:
49
+ candidate@address.com 0.1708
50
+ charles@poodles.com 0.1823
52
51
  ```
53
- `candidate@address.com` is in the same group as `charles@poodles.com` (Group A),
54
- while `other@address.com` is in Group B with `invalid@fake.fake`
55
- Thus we know that `candidate@address.com` exists in the database, and that
56
- `other@example.com` does not.
57
52
 
58
- To make things a bit friendlier, we can rename groups with the `--a-name` and
59
- `--b-name` options:
60
- ```bash
61
- % timing_attack -q -u http://localhost:3000/login \
62
- -a charles@poodles.com -b invalid@fake.fake \
63
- --a-name 'Valid logins' --b-name 'Invalid logins' \
64
- candidate@address.com other@address.com
65
- ```
66
- ```
67
- Valid logins:
68
- candidate@address.com ~0.1988s
69
- Invalid logins:
70
- other@address.com ~0.1065s
71
- ```
53
+ Note that you don't need to know anything about the database when attacking. It
54
+ is, however, nice to have a bit of information as a sanity check.
55
+
56
+ ## How it works
57
+
58
+ The various inputs are each thrown at the endpoint `--number` times. The
59
+ `--percentile`th percentile of each input's results is considered the
60
+ representative result for that input. Inputs are then sorted according to
61
+ their representative results and the largest spike in their graph is found.
62
+ Results then split into short and long groups according to this spike.
63
+
64
+ The `--mean` flag uses the average of results for a particular input as its
65
+ representative result. The `--median` flag simply uses the 50th percentile.
66
+ According to [Crosby, Wallach, and
67
+ Reidi](https://www.cs.rice.edu/~dwallach/pub/crosby-timing2009.pdf), results
68
+ with percentiles above ~15, median, and mean are all quite noisy, so you should
69
+ probably keep `--percentile` low.
70
+
71
+ I was very surprised to find that I get correct results against remote targets
72
+ with `--num` around 20. Default is 5, as that has been sufficient in my tests
73
+ for LAN and local targets.
72
74
 
73
75
  ## Contributing
74
76
 
@@ -76,14 +78,14 @@ Bug reports and pull requests are welcome [here](https://github.com/ffleming/tim
76
78
 
77
79
  ## Disclaimer
78
80
 
79
- TimingAttack is quick and dirty. It is meant to exploit *known* timing attacks based
80
- upon two known values. TimingAttack is *not* for discovering the existence of
81
- timing-based vulnerabilities.
81
+ TimingAttack is quick and dirty.
82
82
 
83
83
  Also, don't use TimingAttack against machines that aren't yours.
84
84
 
85
85
  ## Todo
86
86
  * Tests
87
- * Better heuristic than naïve mean comparison
88
- * Auto-discovering heuristic that doesn't require example test cases
87
+ * More intelligent filtering than nth-percentile + spike detection
88
+ * CW&R's box test
89
89
  * Customizable query parameters
90
+ * Threading for requests?
91
+ * Custom or just use Typhoeus
data/exe/timing_attack CHANGED
@@ -5,15 +5,17 @@ require 'optparse'
5
5
  options = {}
6
6
  opt_parser = OptionParser.new do |opts|
7
7
  opts.program_name = File.basename(__FILE__)
8
- opts.banner = "#{opts.program_name} [options] -a <input> -b <input> -u <target> <inputs>"
8
+ opts.banner = "#{opts.program_name} [options] -u <target> <inputs>"
9
9
  opts.on("-u URL", "--url URL", "URL of endpoint to profile") { |str| options[:url] = str }
10
- opts.on("-a A_EXAMPLE", "--a-example A_EXAMPLE", "Known test case that belongs to Group A") { |str| options[:a_example] = str }
11
- opts.on("-b B_EXAMPLE", "--b-example B_EXAMPLE", "Known test case that belongs to Group B") { |str| options[:b_example] = str }
12
- opts.on("-n NUM", "--number NUM", "Requests per input") { |num| options[:iterations] = num.to_i }
13
- opts.on("--a-name A_NAME", "Name of Group A") { |str| options[:a_name] = str }
14
- opts.on("--b-name B_NAME", "Name of Group B") { |str| options[:b_name] = str }
10
+ opts.on("-n NUM", "--number NUM", "Requests per input (default: 0.025)") { |num| options[:iterations] = num.to_i }
11
+ opts.on("-t NUM", "--threshold NUM", "Minimum threshold, in seconds, for meaningfulness (default: 0.025)") do |num|
12
+ options[:threshold] = num.to_f
13
+ end
15
14
  opts.on("-p", "--post", "Use POST, not GET") { |bool| options[:method] = bool ? :post : :get }
16
15
  opts.on("-q", "--quiet", "Quiet mode (don't display progress bars)") { |bool| options[:verbose] = !bool }
16
+ opts.on("--mean", "Use mean for calculations") { |bool| options[:mean] = bool }
17
+ opts.on("--median", "Use median for calculations") { |bool| options[:median] = bool }
18
+ opts.on("--percentile N", "Use Nth percentile for calculations (default: 10)") { |num| options[:percentile] = num.to_i }
17
19
  opts.on_tail("-v", "--version", "Print version information") do
18
20
  gem = Gem::Specification.find_by_name('timing_attack')
19
21
  puts "#{gem.name} #{gem.version}"
@@ -29,11 +31,18 @@ rescue OptionParser::InvalidOption => e
29
31
  puts opt_parser
30
32
  exit
31
33
  end
34
+ options[:verbose] = true if options[:verbose].nil?
35
+ if options[:percentile]
36
+ options.delete(:mean)
37
+ elsif options[:median]
38
+ options[:percentile] = 50
39
+ elsif options[:mean]
40
+ options.delete(:percentile)
41
+ end
32
42
 
33
43
  begin
34
- atk = TimingAttack::Attacker.new(inputs: ARGV, options: options)
44
+ atk = TimingAttack::CliAttacker.new(inputs: ARGV, options: options)
35
45
  atk.run!
36
- puts atk
37
46
  rescue ArgumentError => e
38
47
  STDERR.puts e.message
39
48
  puts opt_parser
@@ -0,0 +1,106 @@
1
+ module TimingAttack
2
+ class CliAttacker
3
+ def initialize(inputs: [], options: {})
4
+ @options = DEFAULT_OPTIONS.merge(options)
5
+ raise ArgumentError.new("url is a required argument") unless options.has_key? :url
6
+ raise ArgumentError.new("Need at least 2 inputs") if inputs.count < 2
7
+ raise ArgumentError.new("Iterations can't be < 3") if iterations < 3
8
+ unless @options.has_key? :width
9
+ @options[:width] = inputs.dup.map(&:length).push(30).sort.last
10
+ end
11
+ @attacks = inputs.map { |input| TestCase.new(input: input, options: @options) }
12
+ end
13
+
14
+ def run!
15
+ puts "Target: #{url}" if verbose?
16
+ warmup!
17
+ attack!
18
+ puts report
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :attacks, :options, :grouper
24
+
25
+ def report
26
+ ret = ''
27
+ hsh = grouper.serialize
28
+ if hsh[:spike_delta] < threshold
29
+ ret << "\n* Spike delta of #{sprintf('%.4f', hsh[:spike_delta])} is less than #{sprintf('%.4f', threshold)} * \n\n"
30
+ end
31
+ [:short, :long].each do |sym|
32
+ ret << "#{sym.to_s.capitalize} tests:\n"
33
+ hsh.fetch(sym).each do |input, time|
34
+ ret << " #{input.ljust(width)}"
35
+ ret << sprintf('%.4f', time) << "\n"
36
+ end
37
+ end
38
+ ret
39
+ end
40
+
41
+ def warmup!
42
+ 2.times do
43
+ warmup = TestCase.new(input: attacks.sample.input, options: options)
44
+ warmup.test!
45
+ end
46
+ end
47
+
48
+ def attack!
49
+ iterations.times do
50
+ attacks.each do |attack|
51
+ attack.test!
52
+ attack_bar.increment
53
+ end
54
+ end
55
+ end
56
+
57
+ def grouper
58
+ return @grouper unless @grouper.nil?
59
+ group_by = if options.fetch(:mean, false)
60
+ :mean
61
+ else
62
+ { percentile: options.fetch(:percentile) }
63
+ end
64
+ @grouper = Grouper.new(attacks: attacks, group_by: group_by)
65
+ end
66
+
67
+ def attack_bar
68
+ return null_bar unless verbose?
69
+ @attack_bar ||= ProgressBar.create(title: " Attacking".ljust(15),
70
+ total: iterations * attacks.length,
71
+ format: bar_format
72
+ )
73
+ end
74
+
75
+ def benchmark_bar
76
+ return null_bar unless verbose?
77
+ @benchmark_bar ||= ProgressBar.create(title: " Benchmarking".ljust(15),
78
+ total: iterations * 2,
79
+ format: bar_format
80
+ )
81
+ end
82
+
83
+ def bar_format
84
+ @bar_format ||= "%t (%E) |%B|"
85
+ end
86
+
87
+ def null_bar
88
+ @null_bar_klass ||= Struct.new('NullProgressBar', :increment)
89
+ @null_bar ||= @null_bar_klass.new
90
+ end
91
+
92
+ %i(iterations url verbose width method mean percentile threshold).each do |sym|
93
+ define_method(sym) { options.fetch sym }
94
+ end
95
+ alias_method :verbose?, :verbose
96
+
97
+ DEFAULT_OPTIONS = {
98
+ verbose: false,
99
+ method: :get,
100
+ iterations: 5,
101
+ mean: false,
102
+ threshold: 0.025,
103
+ percentile: 10
104
+ }.freeze
105
+ end
106
+ end
@@ -0,0 +1,99 @@
1
+ module TimingAttack
2
+ class Grouper
3
+ attr_reader :short_tests, :long_tests
4
+ def initialize(attacks: , group_by: {})
5
+ @attacks = attacks
6
+ setup_grouping_opts!(group_by)
7
+ @short_tests = []
8
+ @long_tests = []
9
+ group_attacks
10
+ serialize
11
+ freeze
12
+ end
13
+
14
+ def serialize
15
+ @serialize ||= {}.tap do |h|
16
+ h[:attack_method] = test_method
17
+ h[:attack_args] = test_args
18
+ h[:short] = serialize_tests(short_tests)
19
+ h[:long] = serialize_tests(long_tests)
20
+ h[:spike_delta] = spike_delta
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ ALLOWED_TEST_SYMBOLS = %i(mean median percentile).freeze
27
+
28
+ attr_reader :test_method, :test_args, :attacks, :test_hash, :spike_delta
29
+
30
+ def setup_grouping_opts!(group_by)
31
+ case group_by
32
+ when Symbol
33
+ setup_symbol_opts!(group_by)
34
+ when Hash
35
+ setup_hash_opts!(group_by)
36
+ else
37
+ raise ArgumentError.new("Don't know what to do with #{group_by.class} #{group_by}")
38
+ end
39
+ end
40
+
41
+ def setup_symbol_opts!(symbol)
42
+ case symbol
43
+ when :mean
44
+ @test_method = :mean
45
+ @test_args = []
46
+ when :median
47
+ @test_method = :percentile
48
+ @test_args = [50]
49
+ when :percentile
50
+ @test_method = :percentile
51
+ @test_args = [10]
52
+ else
53
+ raise ArgumentError.new("Allowed symbols are #{ALLOWED_TEST_SYMBOLS.join(', ')}")
54
+ end
55
+ end
56
+
57
+ def setup_hash_opts!(hash)
58
+ raise ArgumentError.new("Must provide configuration to Grouper") if hash.empty?
59
+ key, value = hash.first
60
+ unless ALLOWED_TEST_SYMBOLS.include? key
61
+ raise ArgumentError.new("Allowed keys are #{ALLOWED_TEST_SYMBOLS.join(', ')}")
62
+ end
63
+ @test_method = key
64
+ @test_args = value.is_a?(Array) ? value : [value]
65
+ end
66
+
67
+ def value_from_test(test)
68
+ test.public_send(test_method, *test_args)
69
+ end
70
+
71
+ def serialize_tests(test_arr)
72
+ test_arr.each_with_object({}) do |test, ret|
73
+ ret[test.input] = value_from_test(test)
74
+ end
75
+ end
76
+
77
+ def group_attacks
78
+ spike = decorated_attacks.max { |a,b| a[:delta] <=> b[:delta] }
79
+ index = decorated_attacks.index(spike)
80
+ stripped = decorated_attacks.map {|a| a[:attack] }
81
+ @short_tests = stripped[0..(index-1)]
82
+ @long_tests = stripped[index..-1]
83
+ @spike_delta = spike[:delta]
84
+ end
85
+
86
+ def decorated_attacks
87
+ return @decorated_attacks unless @decorated_attacks.nil?
88
+ sorted = attacks.sort { |a,b| value_from_test(a) <=> value_from_test(b) }
89
+ @decorated_attacks = sorted.each_with_object([]).with_index do |(attack, memo), index|
90
+ delta = if index == 0
91
+ 0.0
92
+ else
93
+ value_from_test(attack) - value_from_test(sorted[index-1])
94
+ end
95
+ memo << { attack: attack, delta: delta }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -5,6 +5,7 @@ module TimingAttack
5
5
  @input = input
6
6
  @options = options
7
7
  @times = []
8
+ @percentiles = []
8
9
  end
9
10
 
10
11
  def test!
@@ -21,36 +22,22 @@ module TimingAttack
21
22
  times.push(diff)
22
23
  end
23
24
 
24
- def to_s
25
- "#{input.ljust(options.fetch(:width))}~#{sprintf('%.4f', mean_time)}s"
25
+ def mean
26
+ times.reduce(:+) / times.size.to_f
26
27
  end
27
28
 
28
- def derive_group_from(a_test: , b_test: )
29
- unless a_test.is_a?(TestCase) && b_test.is_a?(TestCase)
30
- raise ArgumentError.new("a_test and b_test must be TestCases")
29
+ def percentile(n)
30
+ raise ArgumentError.new("Can't have a percentile > 100") if n > 100
31
+ if percentiles[n].nil?
32
+ position = ((times.length - 1) * (n/100.0)).to_i
33
+ percentiles[n] = times.sort[position]
34
+ else
35
+ percentiles[n]
31
36
  end
32
- d_a = (mean_time - a_test.mean_time).abs
33
- d_b = (mean_time - b_test.mean_time).abs
34
- @group_a = (d_a < d_b)
35
- end
36
-
37
- def group_a
38
- raise ArgumentError.new("Have not yet determined group membership") if @group_a.nil?
39
- @group_a
40
- end
41
- alias_method :group_a?, :group_a
42
-
43
- def group_b
44
- !group_a
45
- end
46
- alias_method :group_b?, :group_b
47
-
48
- def mean_time
49
- times.reduce(:+) / times.size.to_f
50
37
  end
51
38
 
52
39
  private
53
40
 
54
- attr_reader :times, :options
41
+ attr_reader :times, :options, :percentiles
55
42
  end
56
43
  end
@@ -1,3 +1,3 @@
1
1
  module TimingAttack
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/timing_attack.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  require 'httparty'
2
2
  require 'ruby-progressbar'
3
3
  require "timing_attack/version"
4
+ require "timing_attack/grouper"
4
5
  require "timing_attack/test_case"
5
- require "timing_attack/attacker"
6
+ require "timing_attack/cli_attacker"
6
7
 
7
8
  module TimingAttack
8
9
  end
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.authors = ["Forrest Fleming"]
10
10
  spec.email = ["ffleming@gmail.com"]
11
11
 
12
- spec.summary = "Performtiming attacks against web applications"
12
+ spec.summary = "Perform timing attacks against web applications"
13
13
  spec.description = "Profile web applications by noting differences in response times based on input values"
14
14
  spec.homepage = "https://www.github.com/ffleming/timing_attack"
15
15
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timing_attack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Forrest Fleming
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-07-16 00:00:00.000000000 Z
11
+ date: 2016-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-progressbar
@@ -99,7 +99,8 @@ files:
99
99
  - bin/setup
100
100
  - exe/timing_attack
101
101
  - lib/timing_attack.rb
102
- - lib/timing_attack/attacker.rb
102
+ - lib/timing_attack/cli_attacker.rb
103
+ - lib/timing_attack/grouper.rb
103
104
  - lib/timing_attack/test_case.rb
104
105
  - lib/timing_attack/version.rb
105
106
  - timing_attack.gemspec
@@ -126,5 +127,5 @@ rubyforge_project:
126
127
  rubygems_version: 2.4.8
127
128
  signing_key:
128
129
  specification_version: 4
129
- summary: Performtiming attacks against web applications
130
+ summary: Perform timing attacks against web applications
130
131
  test_files: []
@@ -1,130 +0,0 @@
1
- module TimingAttack
2
- class Attacker
3
- def initialize(inputs: [], options: {})
4
- @options = DEFAULT_OPTIONS.merge(options)
5
- unless @options.has_key? :width
6
- @options[:width] = [a_name, b_name, *inputs].map(&:length).push(30).sort.last
7
- end
8
- %i(a_example b_example url).each do |arg|
9
- raise ArgumentError.new("#{arg} is a required argument") unless options.has_key? arg
10
- end
11
- @attacks = inputs.map { |input| TestCase.new(input: input, options: @options) }
12
- end
13
-
14
- def run!
15
- puts "Target: #{url}" if verbose?
16
- warmup!
17
- benchmark!
18
- attack!
19
- end
20
-
21
- def to_s
22
- ret = ""
23
- if verbose?
24
- ret << "Benchmark results\n"
25
- ret << " #{a_name.ljust(width)}~#{sprintf('%.4f', a_benchmark.mean_time,)}s\n"
26
- ret << " #{b_name.ljust(width)}~#{sprintf('%.4f', b_benchmark.mean_time)}s\n"
27
- end
28
- ret << attack_string
29
- end
30
-
31
- private
32
-
33
- attr_reader :attacks, :options
34
-
35
- def warmup!
36
- @warmup_test ||= TestCase.new(input: a_example, options: options)
37
- 2.times { @warmup_test.test! }
38
- end
39
-
40
- def benchmark!
41
- iterations.times do
42
- [a_benchmark, b_benchmark].each do |test_case|
43
- test_case.test!
44
- benchmark_bar.increment
45
- end
46
- end
47
- end
48
-
49
- def attack!
50
- iterations.times do
51
- attacks.each do |attack|
52
- attack.test!
53
- attack_bar.increment
54
- end
55
- end
56
- attacks.each { |attack| attack.derive_group_from(a_test: a_benchmark, b_test: b_benchmark) }
57
- end
58
-
59
- def a_benchmark
60
- @a_benchmark ||= TestCase.new(input: a_example, options: options)
61
- end
62
-
63
- def b_benchmark
64
- @b_benchmark ||= TestCase.new(input: b_example, options: options)
65
- end
66
-
67
- def indent(str)
68
- " #{str.ljust(width)}"
69
- end
70
-
71
- def a_attacks
72
- attacks.select { |a| a.group_a? }
73
- end
74
-
75
- def b_attacks
76
- attacks.select { |a| a.group_b? }
77
- end
78
-
79
- def attack_string
80
- ret = ""
81
- unless a_attacks.empty?
82
- ret << "#{a_name}:\n"
83
- ret << a_attacks.map {|a| indent(a.to_s)}.join("\n")
84
- end
85
- unless b_attacks.empty?
86
- ret << "\n#{b_name}:\n"
87
- ret << b_attacks.map {|a| indent(a.to_s)}.join("\n")
88
- end
89
- "#{ret}\n"
90
- end
91
-
92
- def attack_bar
93
- return null_bar unless verbose?
94
- @attack_bar ||= ProgressBar.create(title: " Attacking".ljust(15),
95
- total: iterations * attacks.length,
96
- format: bar_format
97
- )
98
- end
99
-
100
- def benchmark_bar
101
- return null_bar unless verbose?
102
- @benchmark_bar ||= ProgressBar.create(title: " Benchmarking".ljust(15),
103
- total: iterations * 2,
104
- format: bar_format
105
- )
106
- end
107
-
108
- def bar_format
109
- @bar_format ||= "%t (%E) |%B|"
110
- end
111
-
112
- def null_bar
113
- @null_bar_klass ||= Struct.new('NullProgressBar', :increment)
114
- @null_bar ||= @null_bar_klass.new
115
- end
116
-
117
- %i(iterations url verbose a_name b_name a_example b_example width method).each do |sym|
118
- define_method(sym) { options.fetch sym }
119
- end
120
- alias_method :verbose?, :verbose
121
-
122
- DEFAULT_OPTIONS = {
123
- verbose: true,
124
- a_name: "Group A",
125
- b_name: "Group B",
126
- method: :get,
127
- iterations: 5
128
- }.freeze
129
- end
130
- end