timing_attack 0.1.0 → 0.2.1

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
  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