recog-intrigue 2.3.7

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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +37 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
  4. data/.github/ISSUE_TEMPLATE/fingerprint_request.md +27 -0
  5. data/.github/PULL_REQUEST_TEMPLATE +24 -0
  6. data/.gitignore +14 -0
  7. data/.rbenv-gemset +1 -0
  8. data/.rspec +3 -0
  9. data/.ruby-gemset +1 -0
  10. data/.ruby-version +1 -0
  11. data/.travis.yml +25 -0
  12. data/.yardopts +1 -0
  13. data/CONTRIBUTING.md +171 -0
  14. data/COPYING +23 -0
  15. data/Gemfile +10 -0
  16. data/LICENSE +7 -0
  17. data/README.md +85 -0
  18. data/Rakefile +22 -0
  19. data/bin/recog_export +81 -0
  20. data/bin/recog_match +55 -0
  21. data/bin/recog_standardize +118 -0
  22. data/bin/recog_verify +64 -0
  23. data/cpe-remap.yaml +134 -0
  24. data/features/data/failing_banners_fingerprints.xml +20 -0
  25. data/features/data/matching_banners_fingerprints.xml +23 -0
  26. data/features/data/multiple_banners_fingerprints.xml +32 -0
  27. data/features/data/no_tests.xml +3 -0
  28. data/features/data/sample_banner.txt +2 -0
  29. data/features/data/successful_tests.xml +18 -0
  30. data/features/data/tests_with_failures.xml +20 -0
  31. data/features/data/tests_with_warnings.xml +17 -0
  32. data/features/match.feature +36 -0
  33. data/features/support/aruba.rb +3 -0
  34. data/features/support/env.rb +6 -0
  35. data/features/verify.feature +48 -0
  36. data/identifiers/README.md +47 -0
  37. data/identifiers/os_architecture.txt +20 -0
  38. data/identifiers/os_device.txt +52 -0
  39. data/identifiers/os_family.txt +160 -0
  40. data/identifiers/os_product.txt +199 -0
  41. data/identifiers/service_family.txt +185 -0
  42. data/identifiers/service_product.txt +255 -0
  43. data/identifiers/software_class.txt +26 -0
  44. data/identifiers/software_family.txt +91 -0
  45. data/identifiers/software_product.txt +333 -0
  46. data/identifiers/vendor.txt +405 -0
  47. data/lib/recog.rb +4 -0
  48. data/lib/recog/db.rb +78 -0
  49. data/lib/recog/db_manager.rb +31 -0
  50. data/lib/recog/fingerprint.rb +280 -0
  51. data/lib/recog/fingerprint/regexp_factory.rb +56 -0
  52. data/lib/recog/fingerprint/test.rb +18 -0
  53. data/lib/recog/formatter.rb +51 -0
  54. data/lib/recog/match_reporter.rb +77 -0
  55. data/lib/recog/matcher.rb +94 -0
  56. data/lib/recog/matcher_factory.rb +14 -0
  57. data/lib/recog/nizer.rb +347 -0
  58. data/lib/recog/verifier.rb +39 -0
  59. data/lib/recog/verifier_factory.rb +13 -0
  60. data/lib/recog/verify_reporter.rb +86 -0
  61. data/lib/recog/version.rb +3 -0
  62. data/misc/convert_mysql_err +61 -0
  63. data/misc/order.xsl +17 -0
  64. data/recog-intrigue.gemspec +45 -0
  65. data/requirements.txt +2 -0
  66. data/spec/data/best_os_match_1.yml +17 -0
  67. data/spec/data/best_os_match_2.yml +17 -0
  68. data/spec/data/best_service_match_1.yml +17 -0
  69. data/spec/data/smb_native_os.txt +25 -0
  70. data/spec/data/test_fingerprints.xml +36 -0
  71. data/spec/data/verification_fingerprints.xml +86 -0
  72. data/spec/data/whitespaced_fingerprint.xml +5 -0
  73. data/spec/lib/fingerprint_self_test_spec.rb +174 -0
  74. data/spec/lib/recog/db_spec.rb +98 -0
  75. data/spec/lib/recog/fingerprint/regexp_factory_spec.rb +73 -0
  76. data/spec/lib/recog/fingerprint_spec.rb +112 -0
  77. data/spec/lib/recog/formatter_spec.rb +69 -0
  78. data/spec/lib/recog/match_reporter_spec.rb +91 -0
  79. data/spec/lib/recog/nizer_spec.rb +330 -0
  80. data/spec/lib/recog/verify_reporter_spec.rb +113 -0
  81. data/spec/spec_helper.rb +82 -0
  82. data/update_cpes.py +186 -0
  83. data/xml/apache_modules.xml +1911 -0
  84. data/xml/apache_os.xml +273 -0
  85. data/xml/architecture.xml +36 -0
  86. data/xml/dns_versionbind.xml +761 -0
  87. data/xml/fingerprints.xsd +128 -0
  88. data/xml/ftp_banners.xml +1553 -0
  89. data/xml/h323_callresp.xml +603 -0
  90. data/xml/hp_pjl_id.xml +358 -0
  91. data/xml/html_title.xml +1630 -0
  92. data/xml/http_cookies.xml +411 -0
  93. data/xml/http_servers.xml +3195 -0
  94. data/xml/http_wwwauth.xml +595 -0
  95. data/xml/imap_banners.xml +245 -0
  96. data/xml/ldap_searchresult.xml +711 -0
  97. data/xml/mdns_device-info_txt.xml +1796 -0
  98. data/xml/mdns_workstation_txt.xml +15 -0
  99. data/xml/mysql_banners.xml +1649 -0
  100. data/xml/mysql_error.xml +871 -0
  101. data/xml/nntp_banners.xml +82 -0
  102. data/xml/ntp_banners.xml +1223 -0
  103. data/xml/operating_system.xml +629 -0
  104. data/xml/pop_banners.xml +499 -0
  105. data/xml/rsh_resp.xml +76 -0
  106. data/xml/rtsp_servers.xml +76 -0
  107. data/xml/sip_banners.xml +359 -0
  108. data/xml/sip_user_agents.xml +221 -0
  109. data/xml/smb_native_lm.xml +62 -0
  110. data/xml/smb_native_os.xml +662 -0
  111. data/xml/smtp_banners.xml +1690 -0
  112. data/xml/smtp_debug.xml +39 -0
  113. data/xml/smtp_ehlo.xml +49 -0
  114. data/xml/smtp_expn.xml +82 -0
  115. data/xml/smtp_help.xml +157 -0
  116. data/xml/smtp_mailfrom.xml +20 -0
  117. data/xml/smtp_noop.xml +44 -0
  118. data/xml/smtp_quit.xml +29 -0
  119. data/xml/smtp_rcptto.xml +25 -0
  120. data/xml/smtp_rset.xml +26 -0
  121. data/xml/smtp_turn.xml +26 -0
  122. data/xml/smtp_vrfy.xml +89 -0
  123. data/xml/snmp_sysdescr.xml +6507 -0
  124. data/xml/snmp_sysobjid.xml +430 -0
  125. data/xml/ssh_banners.xml +1968 -0
  126. data/xml/telnet_banners.xml +1595 -0
  127. data/xml/x11_banners.xml +232 -0
  128. data/xml/x509_issuers.xml +134 -0
  129. data/xml/x509_subjects.xml +1268 -0
  130. metadata +304 -0
@@ -0,0 +1,31 @@
1
+ module Recog
2
+ class DBManager
3
+ require 'nokogiri'
4
+ require_relative 'db'
5
+
6
+ attr_accessor :path, :databases
7
+
8
+ DefaultDatabasePath = File.expand_path( File.join( File.dirname(__FILE__), "..", "..", "xml") )
9
+
10
+ def initialize(path = DefaultDatabasePath)
11
+ self.path = path
12
+ reload
13
+ end
14
+
15
+ def load_databases
16
+ if File.directory?(self.path)
17
+ Dir[self.path + "/*.xml"].each do |dbxml|
18
+ self.databases << DB.new(dbxml)
19
+ end
20
+ else
21
+ self.databases << DB.new(self.path)
22
+ end
23
+ end
24
+
25
+ def reload
26
+ self.databases = []
27
+ load_databases
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,280 @@
1
+ module Recog
2
+
3
+ # A fingerprint that can be {#match matched} against a particular kind of
4
+ # fingerprintable data, e.g. an HTTP `Server` header
5
+ class Fingerprint
6
+ require_relative 'fingerprint/regexp_factory'
7
+ require_relative 'fingerprint/test'
8
+
9
+ # A human readable name describing this fingerprint
10
+ # @return (see #parse_description)
11
+ attr_reader :name
12
+
13
+ # Regular expression pulled from the {DB} xml file.
14
+ #
15
+ # @see #create_regexp
16
+ # @return [Regexp] the Regexp to try when calling {#match}
17
+ attr_reader :regex
18
+
19
+ # Collection of indexes for capture groups created by {#match}
20
+ #
21
+ # @return (see #parse_params)
22
+ attr_reader :params
23
+
24
+ # Collection of example strings that should {#match} our {#regex}
25
+ #
26
+ # @return (see #parse_examples)
27
+ attr_reader :tests
28
+
29
+ # @param xml [Nokogiri::XML::Element]
30
+ # @param match_key [String] See Recog::DB
31
+ # @param protocol [String] Protocol such as ftp, mssql, http, etc.
32
+ def initialize(xml, match_key=nil, protocol=nil)
33
+ @match_key = match_key
34
+ @protocol = protocol
35
+ @name = parse_description(xml)
36
+ @regex = create_regexp(xml)
37
+ @params = {}
38
+ @tests = []
39
+
40
+ @protocol.downcase! if @protocol
41
+ parse_examples(xml)
42
+ parse_params(xml)
43
+ end
44
+
45
+ def output_diag_data(message, data, exception)
46
+ STDERR.puts message
47
+ STDERR.puts exception.inspect
48
+ STDERR.puts "Length: #{data.length}"
49
+ STDERR.puts "Encoding: #{data.encoding}"
50
+ STDERR.puts "Problematic data:\n#{data}"
51
+ STDERR.puts "Raw bytes:\n#{data.pretty_inspect}\n"
52
+ end
53
+
54
+ # Attempt to match the given string.
55
+ #
56
+ # @param match_string [String]
57
+ # @return [Hash,nil] Keys will be host, service, and os attributes
58
+ def match(match_string)
59
+ # match_string.force_encoding('BINARY') if match_string
60
+ begin
61
+ match_data = @regex.match(match_string)
62
+ rescue Encoding::CompatibilityError => e
63
+ begin
64
+ # Replace invalid UTF-8 characters with spaces, just as DAP does.
65
+ encoded_str = match_string.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => '')
66
+ match_data = @regex.match(encoded_str)
67
+ rescue Exception => e
68
+ output_diag_data('Exception while re-encoding match_string to UTF-8', match_string, e)
69
+ end
70
+ rescue Exception => e
71
+ output_diag_data('Exception while running regex against match_string', match_string, e)
72
+ end
73
+ return if match_data.nil?
74
+
75
+ result = { 'matched' => @name }
76
+ replacements = {}
77
+ @params.each_pair do |k,v|
78
+ pos = v[0]
79
+ if pos == 0
80
+ # A match offset of 0 means this param has a hardcoded value
81
+ result[k] = v[1]
82
+ # if this value uses interpolation, note it for handling later
83
+ v[1].scan(/\{([^\s{}]+)\}/).flatten.each do |replacement|
84
+ replacements[k] ||= Set[]
85
+ replacements[k] << replacement
86
+ end
87
+ else
88
+ # A match offset other than 0 means the value should come from
89
+ # the corresponding match result index
90
+ result[k] = match_data[ pos ]
91
+ end
92
+ end
93
+
94
+ # Use the protocol specified in the XML database if there isn't one
95
+ # provided as part of this fingerprint.
96
+ if @protocol
97
+ unless result['service.protocol']
98
+ result['service.protocol'] = @protocol
99
+ end
100
+ end
101
+
102
+ result['fingerprint_db'] = @match_key if @match_key
103
+
104
+ # for everything identified as using interpolation, do so
105
+ replacements.each_pair do |replacement_k, replacement_vs|
106
+ replacement_vs.each do |replacement|
107
+ if result[replacement]
108
+ result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, result[replacement])
109
+ else
110
+ # if the value uses an interpolated value that does not exist, in general this could be
111
+ # very bad, but over time we have allowed the use of regexes with
112
+ # optional captures that are then used for parts of the asserted
113
+ # fingerprints. This is frequently done for optional version
114
+ # strings. If the key in question is cpe23 and the interpolated
115
+ # value we are trying to replace is version related, use the CPE
116
+ # standard of '-' for the version, otherwise raise and exception as
117
+ # this code currently does not handle interpolation of undefined
118
+ # values in other cases.
119
+ if replacement_k =~ /\.cpe23$/ and replacement =~ /\.version$/
120
+ result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, '-')
121
+ else
122
+ raise "Invalid use of nil interpolated non-version value #{replacement} in non-cpe23 fingerprint param #{replacement_k}"
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ return result
129
+ end
130
+
131
+ # Ensure all the {#params} are valid
132
+ #
133
+ # @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
134
+ # indicate whether a param is valid
135
+ # @yieldparam message [String] A human-readable string explaining the
136
+ # `status`
137
+ def verify_params(&block)
138
+ return if params.empty?
139
+ params.each do |param_name, pos_value|
140
+ pos, value = pos_value
141
+ if pos > 0 && !value.to_s.empty?
142
+ yield :fail, "'#{@name}'s #{param_name} is a non-zero pos but specifies a value of '#{value}'"
143
+ elsif pos == 0 && value.to_s.empty?
144
+ yield :fail, "'#{@name}'s #{param_name} is not a capture (pos=0) but doesn't specify a value"
145
+ end
146
+ end
147
+ end
148
+
149
+ # Ensure all the {#tests} actually match the fingerprint and return the
150
+ # expected capture groups.
151
+ #
152
+ # @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
153
+ # indicate whether a test worked
154
+ # @yieldparam message [String] A human-readable string explaining the
155
+ # `status`
156
+ def verify_tests(&block)
157
+
158
+ # look for the presence of test cases
159
+ if tests.size == 0
160
+ yield :warn, "'#{@name}' has no test cases"
161
+ end
162
+
163
+ # make sure each test case passes
164
+ tests.each do |test|
165
+ result = match(test.content)
166
+ if result.nil?
167
+ yield :fail, "'#{@name}' failed to match #{test.content.inspect} with #{@regex}'"
168
+ next
169
+ end
170
+
171
+ message = test
172
+ status = :success
173
+ # Ensure that all the attributes as provided by the example were parsed
174
+ # out correctly and match the capture group values we expect.
175
+ test.attributes.each do |k, v|
176
+ next if k == '_encoding'
177
+ if !result.has_key?(k) || result[k] != v
178
+ message = "'#{@name}' failed to find expected capture group #{k} '#{v}'. Result was #{result[k]}"
179
+ status = :fail
180
+ break
181
+ end
182
+ end
183
+ yield status, message
184
+ end
185
+
186
+ # make sure there are capture groups for all params that use them
187
+ verify_tests_have_capture_groups(&block)
188
+ end
189
+
190
+ # For fingerprints that specify parameters that are defined by
191
+ # capture groups, ensure that each parameter has at least one test
192
+ # that defines an attribute to test for the correct capture of that
193
+ # parameter.
194
+ #
195
+ # @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
196
+ # indicate whether a test worked
197
+ # @yieldparam message [String] A human-readable string explaining the
198
+ # `status`
199
+ def verify_tests_have_capture_groups(&block)
200
+ capture_group_used = {}
201
+ if !params.empty?
202
+ # get a list of parameters that are defined by capture groups
203
+ params.each do |param_name, pos_value|
204
+ pos, value = pos_value
205
+ if pos > 0 && value.to_s.empty?
206
+ capture_group_used[param_name] = false
207
+ end
208
+ end
209
+ end
210
+
211
+ # match up the fingerprint parameters with test attributes
212
+ tests.each do |test|
213
+ test.attributes.each do |k,v|
214
+ if capture_group_used.has_key?(k)
215
+ capture_group_used[k] = true
216
+ end
217
+ end
218
+ end
219
+
220
+ # alert on untested parameters
221
+ capture_group_used.each do |param_name, param_used|
222
+ if !param_used
223
+ message = "'#{@name}' is missing an example that checks for parameter '#{param_name}' " +
224
+ "messsage which is derived from a capture group"
225
+ yield :warn, message
226
+ end
227
+ end
228
+ end
229
+
230
+ private
231
+
232
+ # @param xml [Nokogiri::XML::Element]
233
+ # @return [Regexp]
234
+ def create_regexp(xml)
235
+ pattern = xml['pattern']
236
+ flags = xml['flags'].to_s.split(',')
237
+ RegexpFactory.build(pattern, flags)
238
+ end
239
+
240
+ # @param xml [Nokogiri::XML::Element]
241
+ # @return [String] Contents of the source XML's `description` tag
242
+ def parse_description(xml)
243
+ element = xml.xpath('description')
244
+ element.empty? ? '' : element.first.content.to_s.gsub(/\s+/, ' ').strip
245
+ end
246
+
247
+ # @param xml [Nokogiri::XML::Element]
248
+ # @return [void]
249
+ def parse_examples(xml)
250
+ elements = xml.xpath('example')
251
+
252
+ elements.each do |elem|
253
+ # convert nokogiri Attributes into a hash of name => value
254
+ attrs = elem.attributes.values.reduce({}) { |a,e| a.merge(e.name => e.value) }
255
+ @tests << Test.new(elem.content, attrs)
256
+ end
257
+
258
+ nil
259
+ end
260
+
261
+ # @param xml [Nokogiri::XML::Element]
262
+ # @return [Hash<String,Array>] Keys are things like `"os.name"`, values are a two
263
+ # element Array. The first element is an index for the capture group that returns
264
+ # that thing. If the index is 0, the second element is a static value for
265
+ # that thing; otherwise it is undefined.
266
+ def parse_params(xml)
267
+ @params = {}.tap do |h|
268
+ xml.xpath('param').each do |param|
269
+ name = param['name']
270
+ pos = param['pos'].to_i
271
+ value = param['value'].to_s
272
+ h[name] = [pos, value]
273
+ end
274
+ end
275
+
276
+ nil
277
+ end
278
+
279
+ end
280
+ end
@@ -0,0 +1,56 @@
1
+
2
+ module Recog
3
+ class Fingerprint
4
+
5
+ #
6
+ # @example
7
+ # r = RegexpFactory.build("^Apache[ -]Coyote/(\d\.\d)$", "REG_ICASE")
8
+ # r.match("Apache-Coyote/1.1")
9
+ #
10
+ module RegexpFactory
11
+
12
+ # Currently, only options relating to case insensitivity and
13
+ # multiline/newline are supported. Because Recog's data is used by tools
14
+ # written in different languages like Ruby and Java, we currently support
15
+ # specifying them in a variety of ways. This map controls how they can
16
+ # be specified.
17
+ #
18
+ # TODO: consider supporting only a simpler variant and require that tools
19
+ # that use Recog data translate accordingly
20
+ FLAG_MAP = {
21
+ # multiline variations
22
+ 'REG_DOT_NEWLINE' => Regexp::MULTILINE,
23
+ 'REG_LINE_ANY_CRLF' => Regexp::MULTILINE,
24
+ 'REG_MULTILINE' => Regexp::MULTILINE,
25
+ # case variations
26
+ 'REG_ICASE' => Regexp::IGNORECASE,
27
+ 'IGNORECASE' => Regexp::IGNORECASE
28
+ }
29
+
30
+ DEFAULT_FLAGS = 0
31
+
32
+ # @return [Regexp]
33
+ def self.build(pattern, flags)
34
+ options = build_options(flags)
35
+ Regexp.new(pattern, options)
36
+ end
37
+
38
+ # Convert string flag names as used in Recog XML into a Fixnum suitable for
39
+ # passing as the `options` parameter to `Regexp.new`
40
+ #
41
+ # @see FLAG_MAP
42
+ # @param flags [Array<String>]
43
+ # @return [Fixnum] Flags for creating a regular expression object
44
+ def self.build_options(flags)
45
+ unsupported_flags = flags.select { |flag| !FLAG_MAP.key?(flag) }
46
+ unless unsupported_flags.empty?
47
+ fail "Unsupported regular expression flags found: #{unsupported_flags.join(',')}. Must be one of: #{FLAG_MAP.keys.join(',')}"
48
+ end
49
+ flags.reduce(DEFAULT_FLAGS) do |sum, flag|
50
+ sum |= (FLAG_MAP[flag] || 0)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
@@ -0,0 +1,18 @@
1
+
2
+ class Recog::Fingerprint::Test
3
+ attr_accessor :content
4
+ attr_accessor :attributes
5
+ def initialize(content, attributes=[])
6
+ @attributes = attributes
7
+
8
+ if @attributes['_encoding'] && @attributes['_encoding'] == 'base64'
9
+ @content = content.to_s.unpack('m*').first
10
+ else
11
+ @content = content
12
+ end
13
+ end
14
+
15
+ def to_s
16
+ content
17
+ end
18
+ end
@@ -0,0 +1,51 @@
1
+ module Recog
2
+ class Formatter
3
+ COLORS = {
4
+ :red => 31,
5
+ :yellow => 33,
6
+ :green => 32,
7
+ :white => 15
8
+ }
9
+
10
+ attr_reader :options, :output
11
+
12
+ def initialize(options, output)
13
+ @options = options
14
+ @output = output || StringIO.new
15
+ end
16
+
17
+ def status_message(text)
18
+ output.puts color(text, :white)
19
+ end
20
+
21
+ def success_message(text)
22
+ output.puts color(text, :green)
23
+ end
24
+
25
+ def warning_message(text)
26
+ output.puts color(text, :yellow)
27
+ end
28
+
29
+ def failure_message(text)
30
+ output.puts color(text, :red)
31
+ end
32
+
33
+ private
34
+
35
+ def color_enabled?
36
+ options.color
37
+ end
38
+
39
+ def color(text, color_code)
40
+ color_enabled? ? colorize(text, color_code) : text
41
+ end
42
+
43
+ def colorize(text, color_code)
44
+ "\e[#{color_code_for(color_code)}m#{text}\e[0m"
45
+ end
46
+
47
+ def color_code_for(code)
48
+ COLORS.fetch(code) { COLORS.fetch(:white) }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,77 @@
1
+ module Recog
2
+ class MatchReporter
3
+ attr_reader :formatter
4
+ attr_reader :line_count, :match_count, :fail_count
5
+
6
+ def initialize(options, formatter)
7
+ @options = options
8
+ @formatter = formatter
9
+ reset_counts
10
+ end
11
+
12
+ def report
13
+ reset_counts
14
+ yield self
15
+ summarize unless @options.quiet
16
+ end
17
+
18
+ def stop?
19
+ return false unless @options.fail_fast
20
+ @fail_count >= @options.stop_after
21
+ end
22
+
23
+ def increment_line_count
24
+ @line_count += 1
25
+ end
26
+
27
+ def match(text)
28
+ @match_count += 1
29
+ formatter.success_message(text)
30
+ end
31
+
32
+ def failure(text)
33
+ @fail_count += 1
34
+ formatter.failure_message(text)
35
+ end
36
+
37
+ def print_summary
38
+ colorize_summary(summary_line)
39
+ end
40
+
41
+ private
42
+
43
+ def reset_counts
44
+ @line_count = @match_count = @fail_count = 0
45
+ end
46
+
47
+ def detail?
48
+ @options.detail
49
+ end
50
+
51
+ def summarize
52
+ if detail?
53
+ print_lines_processed
54
+ print_summary
55
+ end
56
+ end
57
+
58
+ def print_lines_processed
59
+ formatter.status_message("\nProcessed #{line_count} lines")
60
+ end
61
+
62
+ def summary_line
63
+ summary = "SUMMARY: "
64
+ summary << "#{match_count} matches"
65
+ summary << " and #{fail_count} failures"
66
+ summary
67
+ end
68
+
69
+ def colorize_summary(summary)
70
+ if @fail_count > 0
71
+ formatter.failure_message(summary)
72
+ else
73
+ formatter.success_message(summary)
74
+ end
75
+ end
76
+ end
77
+ end