recog 3.1.1 → 3.1.2
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/Gemfile +6 -0
- data/Rakefile +7 -5
- data/lib/recog/db.rb +67 -68
- data/lib/recog/db_manager.rb +22 -21
- data/lib/recog/fingerprint/regexp_factory.rb +10 -13
- data/lib/recog/fingerprint/test.rb +9 -8
- data/lib/recog/fingerprint.rb +252 -262
- data/lib/recog/fingerprint_parse_error.rb +3 -1
- data/lib/recog/formatter.rb +41 -39
- data/lib/recog/match_reporter.rb +82 -83
- data/lib/recog/matcher.rb +37 -40
- data/lib/recog/matcher_factory.rb +7 -6
- data/lib/recog/nizer.rb +218 -224
- data/lib/recog/verifier.rb +30 -28
- data/lib/recog/verify_reporter.rb +69 -73
- data/lib/recog/version.rb +3 -1
- data/lib/recog.rb +2 -0
- data/recog/bin/recog_match +21 -20
- data/recog/xml/apache_modules.xml +2 -0
- data/recog/xml/dhcp_vendor_class.xml +1 -1
- data/recog/xml/favicons.xml +133 -1
- data/recog/xml/ftp_banners.xml +1 -1
- data/recog/xml/html_title.xml +140 -1
- data/recog/xml/http_cookies.xml +20 -2
- data/recog/xml/http_servers.xml +38 -17
- data/recog/xml/http_wwwauth.xml +17 -4
- data/recog/xml/mdns_device-info_txt.xml +49 -15
- data/recog/xml/sip_banners.xml +0 -2
- data/recog/xml/sip_user_agents.xml +1 -1
- data/recog/xml/snmp_sysdescr.xml +1 -2
- data/recog/xml/ssh_banners.xml +8 -0
- data/recog/xml/telnet_banners.xml +3 -2
- data/recog/xml/tls_jarm.xml +1 -1
- data/recog/xml/x11_banners.xml +1 -0
- data/recog/xml/x509_issuers.xml +1 -1
- data/recog/xml/x509_subjects.xml +0 -1
- data/recog.gemspec +14 -13
- data/spec/lib/recog/db_spec.rb +37 -36
- data/spec/lib/recog/fingerprint/regexp_factory_spec.rb +19 -20
- data/spec/lib/recog/fingerprint_spec.rb +44 -42
- data/spec/lib/recog/formatter_spec.rb +20 -18
- data/spec/lib/recog/match_reporter_spec.rb +35 -30
- data/spec/lib/recog/nizer_spec.rb +85 -101
- data/spec/lib/recog/verify_reporter_spec.rb +45 -44
- data/spec/spec_helper.rb +2 -1
- data.tar.gz.sig +1 -3
- metadata +3 -3
- metadata.gz.sig +0 -0
data/lib/recog/fingerprint.rb
CHANGED
@@ -1,316 +1,306 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
#
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
3
|
+
module Recog
|
4
|
+
# A fingerprint that can be {#match matched} against a particular kind of
|
5
|
+
# fingerprintable data, e.g. an HTTP `Server` header
|
6
|
+
class Fingerprint
|
7
|
+
require 'set'
|
8
|
+
|
9
|
+
require 'recog/fingerprint_parse_error'
|
10
|
+
require 'recog/fingerprint/regexp_factory'
|
11
|
+
require 'recog/fingerprint/test'
|
12
|
+
|
13
|
+
# A human readable name describing this fingerprint
|
14
|
+
# @return (see #parse_description)
|
15
|
+
attr_reader :name
|
16
|
+
|
17
|
+
# Regular expression pulled from the {DB} xml file.
|
18
|
+
#
|
19
|
+
# @see #create_regexp
|
20
|
+
# @return [Regexp] the Regexp to try when calling {#match}
|
21
|
+
attr_reader :regex
|
22
|
+
|
23
|
+
# Collection of indexes for capture groups created by {#match}
|
24
|
+
#
|
25
|
+
# @return (see #parse_params)
|
26
|
+
attr_reader :params
|
27
|
+
|
28
|
+
# Collection of example strings that should {#match} our {#regex}
|
29
|
+
#
|
30
|
+
# @return (see #parse_examples)
|
31
|
+
attr_reader :tests
|
32
|
+
|
33
|
+
# The line number of the XML entity in the source file for this
|
34
|
+
# fingerprint.
|
35
|
+
#
|
36
|
+
# @return [Integer] The line number of this entity.
|
37
|
+
attr_reader :line
|
38
|
+
|
39
|
+
# @param xml [Nokogiri::XML::Element]
|
40
|
+
# @param match_key [String] See Recog::DB
|
41
|
+
# @param protocol [String] Protocol such as ftp, mssql, http, etc.
|
42
|
+
# @param example_path [String] Directory path for fingerprint example files
|
43
|
+
def initialize(xml, match_key = nil, protocol = nil, example_path = nil)
|
44
|
+
@match_key = match_key
|
45
|
+
@protocol = protocol&.downcase
|
46
|
+
@name = parse_description(xml)
|
47
|
+
@regex = create_regexp(xml)
|
48
|
+
@line = xml.line
|
49
|
+
@params = {}
|
50
|
+
@tests = []
|
51
|
+
|
52
|
+
parse_examples(xml, example_path)
|
53
|
+
parse_params(xml)
|
54
|
+
end
|
55
55
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
56
|
+
def output_diag_data(message, data, exception)
|
57
|
+
$stderr.puts message
|
58
|
+
$stderr.puts exception.inspect
|
59
|
+
$stderr.puts "Length: #{data.length}"
|
60
|
+
$stderr.puts "Encoding: #{data.encoding}"
|
61
|
+
$stderr.puts "Problematic data:\n#{data}"
|
62
|
+
$stderr.puts "Raw bytes:\n#{data.pretty_inspect}\n"
|
63
|
+
end
|
64
64
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
begin
|
72
|
-
match_data = @regex.match(match_string)
|
73
|
-
rescue Encoding::CompatibilityError => e
|
65
|
+
# Attempt to match the given string.
|
66
|
+
#
|
67
|
+
# @param match_string [String]
|
68
|
+
# @return [Hash,nil] Keys will be host, service, and os attributes
|
69
|
+
def match(match_string)
|
70
|
+
# match_string.force_encoding('BINARY') if match_string
|
74
71
|
begin
|
75
|
-
|
76
|
-
|
77
|
-
|
72
|
+
match_data = @regex.match(match_string)
|
73
|
+
rescue Encoding::CompatibilityError
|
74
|
+
begin
|
75
|
+
# Replace invalid UTF-8 characters with spaces, just as DAP does.
|
76
|
+
encoded_str = match_string.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
77
|
+
match_data = @regex.match(encoded_str)
|
78
|
+
rescue Exception => e
|
79
|
+
output_diag_data('Exception while re-encoding match_string to UTF-8', match_string, e)
|
80
|
+
end
|
78
81
|
rescue Exception => e
|
79
|
-
output_diag_data('Exception while
|
82
|
+
output_diag_data('Exception while running regex against match_string', match_string, e)
|
80
83
|
end
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
84
|
+
return if match_data.nil?
|
85
|
+
|
86
|
+
result = { 'matched' => @name }
|
87
|
+
replacements = {}
|
88
|
+
@params.each_pair do |k, v|
|
89
|
+
pos = v[0]
|
90
|
+
if pos == 0
|
91
|
+
# A match offset of 0 means this param has a hardcoded value
|
92
|
+
result[k] = v[1]
|
93
|
+
# if this value uses interpolation, note it for handling later
|
94
|
+
v[1].scan(/\{([^\s{}]+)\}/).flatten.each do |replacement|
|
95
|
+
replacements[k] ||= Set[]
|
96
|
+
replacements[k] << replacement
|
97
|
+
end
|
98
|
+
else
|
99
|
+
# A match offset other than 0 means the value should come from
|
100
|
+
# the corresponding match result index
|
101
|
+
result[k] = match_data[pos]
|
97
102
|
end
|
98
|
-
else
|
99
|
-
# A match offset other than 0 means the value should come from
|
100
|
-
# the corresponding match result index
|
101
|
-
result[k] = match_data[ pos ]
|
102
103
|
end
|
103
|
-
end
|
104
104
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
unless result['service.protocol']
|
109
|
-
result['service.protocol'] = @protocol
|
110
|
-
end
|
111
|
-
end
|
105
|
+
# Use the protocol specified in the XML database if there isn't one
|
106
|
+
# provided as part of this fingerprint.
|
107
|
+
result['service.protocol'] = @protocol if @protocol && !(result['service.protocol'])
|
112
108
|
|
113
|
-
|
109
|
+
result['fingerprint_db'] = @match_key if @match_key
|
114
110
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
else
|
121
|
-
# if the value uses an interpolated value that does not exist, in general this could be
|
122
|
-
# very bad, but over time we have allowed the use of regexes with
|
123
|
-
# optional captures that are then used for parts of the asserted
|
124
|
-
# fingerprints. This is frequently done for optional version
|
125
|
-
# strings. If the key in question is cpe23 and the interpolated
|
126
|
-
# value we are trying to replace is version related, use the CPE
|
127
|
-
# standard of '-' for the version, otherwise raise and exception as
|
128
|
-
# this code currently does not handle interpolation of undefined
|
129
|
-
# values in other cases.
|
130
|
-
if replacement_k =~ /\.cpe23$/ and replacement =~ /\.version$/
|
131
|
-
result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, '-')
|
111
|
+
# for everything identified as using interpolation, do so
|
112
|
+
replacements.each_pair do |replacement_k, replacement_vs|
|
113
|
+
replacement_vs.each do |replacement|
|
114
|
+
if result[replacement]
|
115
|
+
result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, result[replacement])
|
132
116
|
else
|
133
|
-
|
117
|
+
# if the value uses an interpolated value that does not exist, in general this could be
|
118
|
+
# very bad, but over time we have allowed the use of regexes with
|
119
|
+
# optional captures that are then used for parts of the asserted
|
120
|
+
# fingerprints. This is frequently done for optional version
|
121
|
+
# strings. If the key in question is cpe23 and the interpolated
|
122
|
+
# value we are trying to replace is version related, use the CPE
|
123
|
+
# standard of '-' for the version, otherwise raise and exception as
|
124
|
+
# this code currently does not handle interpolation of undefined
|
125
|
+
# values in other cases.
|
126
|
+
raise "Invalid use of nil interpolated non-version value #{replacement} in non-cpe23 fingerprint param #{replacement_k}" unless replacement_k =~ (/\.cpe23$/) && replacement =~ (/\.version$/)
|
127
|
+
|
128
|
+
result[replacement_k] = result[replacement_k].gsub(/\{#{replacement}\}/, '-')
|
129
|
+
|
134
130
|
end
|
135
131
|
end
|
136
132
|
end
|
137
|
-
end
|
138
133
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
result.delete(k)
|
134
|
+
# After performing interpolation, remove temporary keys from results
|
135
|
+
result.each_pair do |k, _|
|
136
|
+
result.delete(k) if k.start_with?('_tmp.')
|
143
137
|
end
|
138
|
+
|
139
|
+
result
|
144
140
|
end
|
145
141
|
|
146
|
-
|
147
|
-
|
142
|
+
# Ensure all the {#params} are valid
|
143
|
+
#
|
144
|
+
# @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
|
145
|
+
# indicate whether a param is valid
|
146
|
+
# @yieldparam message [String] A human-readable string explaining the
|
147
|
+
# `status`
|
148
|
+
def verify_params
|
149
|
+
return if params.empty?
|
148
150
|
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
return if params.empty?
|
157
|
-
params.each do |param_name, pos_value|
|
158
|
-
pos, value = pos_value
|
159
|
-
if pos > 0 && !value.to_s.empty?
|
160
|
-
yield :fail, "'#{@name}'s #{param_name} is a non-zero pos but specifies a value of '#{value}'"
|
161
|
-
elsif pos == 0 && value.to_s.empty?
|
162
|
-
yield :fail, "'#{@name}'s #{param_name} is not a capture (pos=0) but doesn't specify a value"
|
151
|
+
params.each do |param_name, pos_value|
|
152
|
+
pos, value = pos_value
|
153
|
+
if pos > 0 && !value.to_s.empty?
|
154
|
+
yield :fail, "'#{@name}'s #{param_name} is a non-zero pos but specifies a value of '#{value}'"
|
155
|
+
elsif pos == 0 && value.to_s.empty?
|
156
|
+
yield :fail, "'#{@name}'s #{param_name} is not a capture (pos=0) but doesn't specify a value"
|
157
|
+
end
|
163
158
|
end
|
164
159
|
end
|
165
|
-
end
|
166
160
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
return
|
180
|
-
end
|
181
|
-
|
182
|
-
# make sure each test case passes
|
183
|
-
tests.each do |test|
|
184
|
-
result = match(test.content)
|
185
|
-
if result.nil?
|
186
|
-
yield :fail, "'#{@name}' failed to match #{test.content.inspect} with #{@regex}'"
|
187
|
-
next
|
161
|
+
# Ensure all the {#tests} actually match the fingerprint and return the
|
162
|
+
# expected capture groups.
|
163
|
+
#
|
164
|
+
# @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
|
165
|
+
# indicate whether a test worked
|
166
|
+
# @yieldparam message [String] A human-readable string explaining the
|
167
|
+
# `status`
|
168
|
+
def verify_tests(&block)
|
169
|
+
# look for the presence of test cases
|
170
|
+
if tests.size == 0
|
171
|
+
yield :warn, "'#{@name}' has no test cases"
|
172
|
+
return
|
188
173
|
end
|
189
174
|
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
175
|
+
# make sure each test case passes
|
176
|
+
tests.each do |test|
|
177
|
+
result = match(test.content)
|
178
|
+
if result.nil?
|
179
|
+
yield :fail, "'#{@name}' failed to match #{test.content.inspect} with #{@regex}'"
|
180
|
+
next
|
181
|
+
end
|
182
|
+
|
183
|
+
message = test
|
184
|
+
status = :success
|
185
|
+
# Ensure that all the attributes as provided by the example were parsed
|
186
|
+
# out correctly and match the capture group values we expect.
|
187
|
+
test.attributes.each do |k, v|
|
188
|
+
next if k == '_encoding'
|
189
|
+
next if k == '_filename'
|
190
|
+
|
191
|
+
next unless !result.key?(k) || result[k] != v
|
192
|
+
|
198
193
|
message = "'#{@name}' failed to find expected capture group #{k} '#{v}'. Result was #{result[k]}"
|
199
194
|
status = :fail
|
200
195
|
break
|
201
196
|
end
|
197
|
+
yield status, message
|
202
198
|
end
|
203
|
-
yield status, message
|
204
|
-
end
|
205
199
|
|
206
|
-
|
207
|
-
|
208
|
-
|
200
|
+
# make sure there are capture groups for all params that use them
|
201
|
+
verify_tests_have_capture_groups(&block)
|
202
|
+
end
|
209
203
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
capture_group_used[param_name] = false
|
204
|
+
# For fingerprints that specify parameters that are defined by
|
205
|
+
# capture groups, ensure that each parameter has at least one test
|
206
|
+
# that defines an attribute to test for the correct capture of that
|
207
|
+
# parameter.
|
208
|
+
#
|
209
|
+
# @yieldparam status [Symbol] One of `:warn`, `:fail`, or `:success` to
|
210
|
+
# indicate whether a test worked
|
211
|
+
# @yieldparam message [String] A human-readable string explaining the
|
212
|
+
# `status`
|
213
|
+
def verify_tests_have_capture_groups
|
214
|
+
capture_group_used = {}
|
215
|
+
unless params.empty?
|
216
|
+
# get a list of parameters that are defined by capture groups
|
217
|
+
params.each do |param_name, pos_value|
|
218
|
+
pos, value = pos_value
|
219
|
+
capture_group_used[param_name] = false if pos > 0 && value.to_s.empty?
|
227
220
|
end
|
228
221
|
end
|
229
|
-
end
|
230
222
|
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
capture_group_used[k] = true
|
223
|
+
# match up the fingerprint parameters with test attributes
|
224
|
+
tests.each do |test|
|
225
|
+
test.attributes.each do |k, _v|
|
226
|
+
capture_group_used[k] = true if capture_group_used.key?(k)
|
236
227
|
end
|
237
228
|
end
|
238
|
-
end
|
239
229
|
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
230
|
+
# alert on untested parameters unless they are temporary
|
231
|
+
capture_group_used.each do |param_name, param_used|
|
232
|
+
next unless !param_used && !param_name.start_with?('_tmp.')
|
233
|
+
|
234
|
+
message = "'#{@name}' is missing an example that checks for parameter '#{param_name}' " \
|
235
|
+
'which is derived from a capture group'
|
245
236
|
yield :fail, message
|
246
237
|
end
|
247
238
|
end
|
248
|
-
end
|
249
239
|
|
250
|
-
|
240
|
+
private
|
251
241
|
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
242
|
+
# @param xml [Nokogiri::XML::Element]
|
243
|
+
# @return [Regexp]
|
244
|
+
def create_regexp(xml)
|
245
|
+
pattern = xml['pattern']
|
246
|
+
flags = xml['flags'].to_s.split(',')
|
247
|
+
RegexpFactory.build(pattern, flags)
|
248
|
+
end
|
259
249
|
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
250
|
+
# @param xml [Nokogiri::XML::Element]
|
251
|
+
# @return [String] Contents of the source XML's `description` tag
|
252
|
+
def parse_description(xml)
|
253
|
+
element = xml.xpath('description')
|
254
|
+
element.empty? ? '' : element.first.content.to_s.gsub(/\s+/, ' ').strip
|
255
|
+
end
|
266
256
|
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
257
|
+
# @param xml [Nokogiri::XML::Element]
|
258
|
+
# @param example_path [String] Directory path for fingerprint example files
|
259
|
+
# @return [void]
|
260
|
+
def parse_examples(xml, example_path)
|
261
|
+
elements = xml.xpath('example')
|
262
|
+
|
263
|
+
elements.each do |elem|
|
264
|
+
# convert nokogiri Attributes into a hash of name => value
|
265
|
+
attrs = elem.attributes.values.reduce({}) { |a, e| a.merge(e.name => e.value) }
|
266
|
+
if attrs['_filename']
|
267
|
+
contents = ''
|
268
|
+
filename = attrs['_filename']
|
269
|
+
fn = File.expand_path(File.join(example_path, filename))
|
270
|
+
unless fn.start_with?(File.expand_path(example_path) + File::Separator)
|
271
|
+
raise FingerprintParseError.new("an example specifies an illegal file path '#{filename}'",
|
272
|
+
@line)
|
273
|
+
end
|
283
274
|
|
284
|
-
|
285
|
-
|
286
|
-
|
275
|
+
File.open(fn, 'rb') do |file|
|
276
|
+
contents = file.read
|
277
|
+
contents.force_encoding(Encoding::ASCII_8BIT)
|
278
|
+
end
|
279
|
+
@tests << Test.new(contents, attrs)
|
280
|
+
else
|
281
|
+
@tests << Test.new(elem.content, attrs)
|
287
282
|
end
|
288
|
-
@tests << Test.new(contents, attrs)
|
289
|
-
else
|
290
|
-
@tests << Test.new(elem.content, attrs)
|
291
283
|
end
|
292
|
-
end
|
293
284
|
|
294
|
-
|
295
|
-
|
285
|
+
nil
|
286
|
+
end
|
296
287
|
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
288
|
+
# @param xml [Nokogiri::XML::Element]
|
289
|
+
# @return [Hash<String,Array>] Keys are things like `"os.name"`, values are a two
|
290
|
+
# element Array. The first element is an index for the capture group that returns
|
291
|
+
# that thing. If the index is 0, the second element is a static value for
|
292
|
+
# that thing; otherwise it is undefined.
|
293
|
+
def parse_params(xml)
|
294
|
+
@params = {}.tap do |h|
|
295
|
+
xml.xpath('param').each do |param|
|
296
|
+
name = param['name']
|
297
|
+
pos = param['pos'].to_i
|
298
|
+
value = param['value'].to_s
|
299
|
+
h[name] = [pos, value]
|
300
|
+
end
|
309
301
|
end
|
310
|
-
end
|
311
302
|
|
312
|
-
|
303
|
+
nil
|
304
|
+
end
|
313
305
|
end
|
314
|
-
|
315
|
-
end
|
316
306
|
end
|