nexpose_ticketing 1.0.2 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,441 +1,470 @@
1
- module NexposeTicketing
2
- #
3
- # The Nexpose Ticketing service.
4
- #
5
- =begin
6
-
7
- Copyright (C) 2014, Rapid7 LLC
8
- All rights reserved.
9
-
10
- Redistribution and use in source and binary forms, with or without modification,
11
- are permitted provided that the following conditions are met:
12
-
13
- * Redistributions of source code must retain the above copyright notice,
14
- this list of conditions and the following disclaimer.
15
-
16
- * Redistributions in binary form must reproduce the above copyright notice,
17
- this list of conditions and the following disclaimer in the documentation
18
- and/or other materials provided with the distribution.
19
-
20
- * Neither the name of Rapid7 LLC nor the names of its contributors
21
- may be used to endorse or promote products derived from this software
22
- without specific prior written permission.
23
-
24
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25
- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26
- WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
28
- ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
29
- (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
30
- LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
31
- ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
32
- (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
33
- SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
-
35
- =end
36
-
37
- #
38
- # WARNING! This code makes an SSL connection to the Nexpose server, but does NOT
39
- # verify the certificate at this time. This can be a security issue if
40
- # an attacker is able to man-in-the-middle the connection between the
41
- # Metasploit console and the Nexpose server. In the common case of
42
- # running Nexpose and Metasploit on the same host, this is a low risk.
43
- #
44
-
45
- #
46
- # WARNING! This code is still rough and going through substantive changes. While
47
- # you can build tools using this library today, keep in mind that
48
- # method names and parameters may change in the future.
49
- #
50
- class TicketService
51
- require 'csv'
52
- require 'yaml'
53
- require 'fileutils'
54
- require 'nexpose_ticketing/ticket_repository'
55
- require 'nexpose_ticketing'
56
- require 'nexpose_ticketing/nx_logger'
57
- require 'nexpose_ticketing/version'
58
-
59
- TICKET_SERVICE_CONFIG_PATH = File.join(File.dirname(__FILE__), '/config/ticket_service.config')
60
- LOGGER_FILE = File.join(File.dirname(__FILE__), '/logs/ticket_service.log')
61
-
62
- attr_accessor :helper_data, :nexpose_data, :options, :ticket_repository, :first_time, :nexpose_item_histories
63
-
64
- def setup(helper_data)
65
- # Gets the Ticket Service configuration.
66
- service_data = begin
67
- YAML.load_file(TICKET_SERVICE_CONFIG_PATH)
68
- rescue ArgumentError => e
69
- raise "Could not parse YAML #{TICKET_SERVICE_CONFIG_PATH} : #{e.message}"
70
- end
71
- @helper_data = helper_data
72
- @nexpose_data = service_data[:nexpose_data]
73
- @options = service_data[:options]
74
- @options[:file_name] = "#{@options[:file_name]}"
75
-
76
- # Setups logging if enabled.
77
- setup_logging(@options[:logging_enabled])
78
-
79
- # Loads all the helpers.
80
- log_message('Loading helpers.')
81
- Dir[File.join(File.dirname(__FILE__), '/helpers/*.rb')].each do |file|
82
- log_message("Loading helper: #{file}")
83
- require_relative file
84
- end
85
- log_message("Ticket mode: #{@options[:ticket_mode]}.")
86
-
87
- log_message("Enabling helper: #{@helper_data[:helper_name]}.")
88
- @helper = eval(@helper_data[:helper_name]).new(@helper_data, @options)
89
-
90
- log_message("Creating ticketing repository with timeout value: #{@options[:timeout]}.")
91
- @ticket_repository = NexposeTicketing::TicketRepository.new(options)
92
- @ticket_repository.nexpose_login(@nexpose_data)
93
- end
94
-
95
- def setup_logging(enabled = false)
96
- helper_log = NexposeTicketing::NxLogger.instance
97
- helper_log.setup_logging(@options[:logging_enabled],
98
- @options[:log_level])
99
-
100
- return unless enabled
101
- require 'logger'
102
- directory = File.dirname(LOGGER_FILE)
103
- FileUtils.mkdir_p(directory) unless File.directory?(directory)
104
- @log = Logger.new(LOGGER_FILE, 'monthly')
105
- @log.level = Logger::INFO
106
- log_message('Logging enabled, starting service.')
107
- end
108
-
109
- # Logs a message if logging is enabled.
110
- def log_message(message)
111
- @log.info(message) if @options[:logging_enabled]
112
- end
113
-
114
- # Prepares all the local and nexpose historical data.
115
- def prepare_historical_data(ticket_repository, options)
116
- (options[:tag_run]) ?
117
- historical_scan_file = File.join(File.dirname(__FILE__), "#{options[:tag_file_name]}") :
118
- historical_scan_file = File.join(File.dirname(__FILE__), "#{options[:file_name]}")
119
-
120
- if File.exists?(historical_scan_file)
121
- log_message("Reading historical CSV file: #{historical_scan_file}.")
122
- file_site_histories = ticket_repository.read_last_scans(historical_scan_file)
123
- else
124
- file_site_histories = nil
125
- end
126
- file_site_histories
127
- end
128
-
129
- # Generates a full site(s) report ticket(s).
130
- def all_site_report(ticket_repository, options, helper)
131
- if(options[:tag_run])
132
- log_message('Generating full vulnerability report on user entered tags.')
133
- items_to_query = Array(options[:tags])
134
- log_message("Generating full vulnerability report on the following tags: #{items_to_query}")
135
- else
136
- log_message('Generating full vulnerability report on user entered sites.')
137
- items_to_query = Array(options[:sites])
138
- log_message("Generating full vulnerability report on the following sites: #{items_to_query.join(', ')}")
139
- end
140
- items_to_query.each { |item|
141
- log_message("Running full vulnerability report on item #{item}")
142
- all_vulns_file = ticket_repository.all_vulns(options, item)
143
- log_message('Preparing tickets.')
144
- ticket_rate_limiter(options, all_vulns_file, Proc.new { |ticket_batch| helper.prepare_create_tickets(ticket_batch, options[:tag_run] ? "T#{item}" : item) }, Proc.new { |tickets| helper.create_tickets(tickets) })
145
- }
146
-
147
- if(options[:tag_run])
148
- items_to_query. each { |item_id|
149
- tag_assets_historic_file = File.join(File.dirname(__FILE__), 'tag_assets', "#{options[:tag_file_name]}_#{item_id}.csv")
150
- ticket_repository.generate_tag_asset_list(tags: item_id,
151
- csv_file: tag_assets_historic_file)
152
- }
153
- end
154
- log_message('Finished process all vulnerabilities.')
155
- end
156
-
157
- # There's possibly a new scan with new data.
158
- def delta_site_report(ticket_repository, options, helper, file_site_histories)
159
- # Compares the scan information from file && Nexpose.
160
- no_processing = true
161
- @nexpose_item_histories.each do |item_id, last_scan_id|
162
- # There's no entry in the file, so it's either a new item in Nexpose or a new item we have to monitor.
163
- if file_site_histories[item_id].nil? || file_site_histories[item_id] == -1
164
- full_new_site_report(item_id, ticket_repository, options, helper)
165
- if(options[:tag_run])
166
- tag_assets_historic_file = File.join(File.dirname(__FILE__), 'tag_assets', "#{options[:tag_file_name]}_#{item_id}.csv")
167
- ticket_repository.generate_tag_asset_list(tags: item_id,
168
- csv_file: tag_assets_historic_file)
169
- end
170
- no_processing = false
171
- # Site has been scanned since last seen according to the file.
172
- elsif file_site_histories[item_id].to_s != nexpose_item_histories[item_id].to_s
173
- if(options[:tag_run])
174
- # It's a tag run and something has changed (new/removed asset or new scan ID for an asset). To find out what, we must compare
175
- # All tag assets and their scan IDs. Firstly we fetch all the assets in the tags
176
- # in the configuration file and store them temporarily
177
- tag_assets_tmp_file = File.join(File.dirname(__FILE__), "/tag_assets/#{options[:tag_file_name]}_#{item_id}.tmp")
178
- tag_assets_historic_file = File.join(File.dirname(__FILE__), "/tag_assets/#{options[:tag_file_name]}_#{item_id}.csv")
179
- ticket_repository.generate_tag_asset_list(tags: item_id,
180
- csv_file: tag_assets_tmp_file)
181
- new_tag_configuration = ticket_repository.read_tag_asset_list(tag_assets_tmp_file)
182
- historic_tag_configuration = ticket_repository.read_tag_asset_list(tag_assets_historic_file)
183
- #Compare the assets within the tags and their scan histories to find the ones we need to query
184
- changed_assets = Hash[*(historic_tag_configuration.to_a - new_tag_configuration.to_a).flatten]
185
- new_assets = Hash[*(new_tag_configuration.to_a - historic_tag_configuration.to_a).flatten]
186
- new_assets.delete_if {|asset_id, scan_id| historic_tag_configuration.has_key?(asset_id.to_s)}
187
- #all_assets_changed = new_assets.merge(changed_assets)
188
- changed_assets.each do |asset_id, scan_id|
189
- delta_site_new_scan(ticket_repository, asset_id, options, helper, changed_assets, item_id)
190
- end
191
- new_assets.each do |asset_id, scan_id|
192
- #Since no previous scan IDs - we generate a full report.
193
- options[:nexpose_item] = asset_id
194
- full_new_site_report(item_id, ticket_repository, options, helper)
195
- options.delete(:nexpose_item)
196
- end
197
- else
198
- delta_site_new_scan(ticket_repository, item_id, options, helper, file_site_histories)
199
- end
200
- if(options[:tag_run])
201
- #Update the historic file
202
- new_tag_asset_list = historic_tag_configuration.merge(new_tag_configuration)
203
- trimmed_csv = []
204
- trimmed_csv << 'asset_id, last_scan_id'
205
- new_tag_asset_list.each do |asset_id, last_scan_id|
206
- trimmed_csv << "#{asset_id},#{last_scan_id}"
207
- end
208
- ticket_repository.save_to_file(tag_assets_historic_file, trimmed_csv)
209
- File.delete(tag_assets_tmp_file)
210
- end
211
- no_processing = false
212
- end
213
- end
214
- # Done processing, update the CSV to the latest scan info.
215
- log_message("Nothing new to process, updating historical CSV file #{options[:file_name]}.") if no_processing
216
- log_message("Done processing, updating historical CSV file #{options[:file_name]}.") unless no_processing
217
- no_processing
218
- end
219
-
220
- # There's a new site we haven't seen before.
221
- def full_new_site_report(nexpose_item, ticket_repository, options, helper)
222
- log_message("New nexpose id: #{nexpose_item} detected. Generating report.")
223
- new_item_vuln_file = ticket_repository.all_vulns(options, nexpose_item)
224
- log_message('Report generated, preparing tickets.')
225
- ticket_rate_limiter(options, new_item_vuln_file, Proc.new {|ticket_batch| helper.prepare_create_tickets(ticket_batch, options[:tag_run] ? "T#{nexpose_item}" : nexpose_item)}, Proc.new {|tickets| helper.create_tickets(tickets)})
226
- end
227
-
228
- # There's a new scan with possibly new vulnerabilities.
229
- def delta_site_new_scan(ticket_repository, nexpose_item, options, helper, file_site_histories, tag_id=nil)
230
- log_message("New scan detected for nexpose id: #{nexpose_item}. Generating report.")
231
- item = options[:tag_run] ? 'asset' : 'site'
232
-
233
- if options[:ticket_mode] == 'I' || options[:ticket_mode] == 'V'
234
- # I-mode and V-mode tickets require updating the tickets in the target system.
235
- log_message("Scan id for new scan: #{file_site_histories[nexpose_item]}.")
236
- all_scan_vuln_file = ticket_repository.all_vulns_since(scan_id: file_site_histories[nexpose_item],
237
- nexpose_item: nexpose_item,
238
- severity: options[:severity],
239
- ticket_mode: options[:ticket_mode],
240
- riskScore: options[:riskScore],
241
- vulnerabilityCategories: options[:vulnerabilityCategories],
242
- tag_run: options[:tag_run],
243
- tag: tag_id)
244
-
245
- if helper.respond_to?('prepare_update_tickets') && helper.respond_to?('update_tickets')
246
- ticket_rate_limiter(options, all_scan_vuln_file, Proc.new {|ticket_batch| helper.prepare_update_tickets(ticket_batch, tag_id.nil? ? nexpose_item : "T#{tag_id}")}, Proc.new {|tickets| helper.update_tickets(tickets)})
247
- else
248
- log_message('Helper does not implement update methods')
249
- fail "Helper using 'I' or 'V' mode must implement prepare_updates and update_tickets"
250
- end
251
-
252
- if options[:close_old_tickets_on_update] == 'Y'
253
- tickets_to_close_file = ticket_repository.tickets_to_close(scan_id: file_site_histories[nexpose_item],
254
- nexpose_item: nexpose_item,
255
- severity: options[:severity],
256
- ticket_mode: options[:ticket_mode],
257
- riskScore: options[:riskScore],
258
- vulnerabilityCategories: options[:vulnerabilityCategories],
259
- tag_run: options[:tag_run],
260
- tag: tag_id)
261
-
262
- if helper.respond_to?('prepare_close_tickets') && helper.respond_to?('close_tickets')
263
- ticket_rate_limiter(options, tickets_to_close_file, Proc.new {|ticket_batch| helper.prepare_close_tickets(ticket_batch, tag_id.nil? ? nexpose_item : "T#{tag_id}")}, Proc.new {|tickets| helper.close_tickets(tickets)})
264
- else
265
- log_message('Helper does not implement close methods')
266
- fail 'Helper using \'I\' or \'V\' mode must implement prepare_close_tickets and close_tickets'
267
- end
268
- end
269
- else
270
- # D-mode tickets require creating new tickets and closing old tickets.
271
- new_scan_vuln_file = ticket_repository.new_vulns(scan_id: file_site_histories[nexpose_item],
272
- nexpose_item: nexpose_item,
273
- severity: options[:severity],
274
- ticket_mode: options[:ticket_mode],
275
- riskScore: options[:riskScore],
276
- vulnerabilityCategories: options[:vulnerabilityCategories],
277
- tag_run: options[:tag_run],
278
- tag: tag_id)
279
-
280
- preparse = CSV.open(new_scan_vuln_file.path, headers: :first_row)
281
- empty_report = preparse.shift.nil?
282
- preparse.close
283
-
284
-
285
- log_message("No new vulnerabilities found in new scan for #{item}: #{nexpose_item}.") if empty_report
286
- log_message("New vulnerabilities found in new scan for #{item} #{nexpose_item}, preparing tickets.") unless empty_report
287
- unless empty_report
288
- ticket_rate_limiter(options, new_scan_vuln_file, Proc.new {|ticket_batch| helper.prepare_create_tickets(ticket_batch, tag_id.nil? ? nexpose_item : "T#{tag_id}")}, Proc.new {|tickets| helper.create_tickets(tickets)})
289
- end
290
-
291
- if helper.respond_to?('prepare_close_tickets') && helper.respond_to?('close_tickets')
292
- old_scan_vuln_file = ticket_repository.old_vulns(scan_id: file_site_histories[nexpose_item],
293
- nexpose_item: nexpose_item,
294
- site_id: nexpose_item,
295
- severity: options[:severity],
296
- riskScore: options[:riskScore],
297
- vulnerabilityCategories: options[:vulnerabilityCategories],
298
- tag_run: options[:tag_run],
299
- tag: tag_id)
300
-
301
- preparse = CSV.open(old_scan_vuln_file.path, headers: :first_row, :skip_blanks => true)
302
- empty_report = preparse.shift.nil?
303
- preparse.close
304
- log_message("No old (closed) vulnerabilities found in new scan for #{item}: #{nexpose_item}.") if empty_report
305
- log_message("Old vulnerabilities found in new scan for #{item} #{nexpose_item}, preparing closures.") unless empty_report
306
- unless empty_report
307
- ticket_rate_limiter(options, old_scan_vuln_file, Proc.new {|ticket_batch| helper.prepare_close_tickets(ticket_batch, tag_id.nil? ? nexpose_item : "T#{tag_id}")}, Proc.new {|tickets| helper.close_tickets(tickets)})
308
- end
309
- else
310
- # Create a log message but do not halt execution of the helper if ticket closing is not
311
- # supported to allow legacy code to execute normally.
312
- log_message('Helper does not implement close methods.')
313
- end
314
- end
315
- end
316
-
317
-
318
- def ticket_rate_limiter(options, query_results_file, ticket_prepare_method, ticket_send_method)
319
- batch_size_max = (options[:batch_size] + 1)
320
- log_message("Batching tickets in sizes: #{options[:batch_size]}")
321
-
322
- #Vulnerability mode is batched by vulnerability_id, IP mode is batched by IP address.
323
- current_id = -1
324
- id_field = if @options[:ticket_mode] == 'V'
325
- 'vulnerability_id'
326
- else
327
- 'ip_address'
328
- end
329
-
330
- # Start the batching
331
- query_results_file.rewind
332
- csv_header = query_results_file.readline
333
- ticket_batch = []
334
- current_csv_row = nil
335
-
336
- begin
337
- CSV.foreach(query_results_file, headers: csv_header) do |row|
338
- ticket_batch << row
339
-
340
- #Keep updating the ID until we hit the batch 'limit'
341
- #Should this be <=??? What happens if value n-1 is different?
342
-
343
- if ticket_batch.size < batch_size_max
344
- current_id = row[id_field]
345
- next
346
- end
347
-
348
- #Keep adding rows to get all information on an asset/vulnerability
349
- next if @options[:ticket_mode] != 'D' && row[id_field] == current_id
350
-
351
- #Last row is mismatch/independent
352
- leftover_row = ticket_batch.pop
353
-
354
- log_message('Batch size reached. Sending tickets.')
355
- ticket_rate_limiter_processor(ticket_batch, ticket_prepare_method, ticket_send_method)
356
-
357
- ticket_batch.clear
358
- ticket_batch << csv_header
359
- ticket_batch << leftover_row
360
- current_id = -1
361
- end
362
- ensure
363
- log_message('Finished reading report. Sending any remaining tickets and cleaning up file system.')
364
-
365
- ticket_rate_limiter_processor(ticket_batch, ticket_prepare_method, ticket_send_method)
366
-
367
- query_results_file.close
368
- query_results_file.unlink
369
- end
370
- end
371
-
372
- def ticket_rate_limiter_processor(ticket_batch, ticket_prepare_method, ticket_send_method)
373
- #Just the header (no tickets).
374
- if ticket_batch.size == 1
375
- log_message('Received empty batch. Not sending tickets.')
376
- return
377
- end
378
-
379
- # Prep the batch of tickets
380
- log_message('Creating tickets.')
381
- tickets = ticket_prepare_method.call(ticket_batch.join(''))
382
- log_message("Parsed rows: #{ticket_batch.size}")
383
- # Sent them off
384
- log_message('Sending tickets.')
385
- ticket_send_method.call(tickets)
386
- log_message('Returning for next batch.')
387
- end
388
-
389
-
390
- # Starts the Ticketing Service.
391
- def start
392
- #Decide if this is a tag run (tags always override sites as the API does not allow for the combination of the two)
393
- @options[:tag_run] = !@options[:tags].nil? && !@options[:tags].empty?
394
-
395
- # Checks if the csv historical file already exists && reads it, otherwise create it && assume first time run.
396
- file_site_histories = prepare_historical_data(@ticket_repository, @options)
397
- historical_scan_file = File.join(File.dirname(__FILE__), "#{@options[:file_name]}")
398
- historical_tag_file = File.join(File.dirname(__FILE__), "#{@options[:tag_file_name]}")
399
-
400
- # If we didn't specify a site || first time run (no scan history), then it gets all the vulnerabilities.
401
- if (((@options[:sites].nil? || @options[:sites].empty? || file_site_histories.nil?) && !@options[:tag_run]) || (@options[:tag_run] && file_site_histories.nil?))
402
- log_message('Storing current scan state before obtaining all vulnerabilities.')
403
- current_scan_state = ticket_repository.load_last_scans(@options)
404
-
405
- if (options[:sites].nil? || options[:sites].empty?) && (!@options[:tag_run])
406
- log_message('No site(s) specified, generating for all sites.')
407
- @ticket_repository.all_site_details.each { |site| (@options[:sites] ||= []) << site.id.to_s }
408
- log_message("List of sites is now <#{@options[:sites]}>")
409
- end
410
-
411
- all_site_report(@ticket_repository, @options, @helper)
412
-
413
- #Generate historical CSV file after completing the fist query.
414
- log_message('No historical CSV file found. Generating.')
415
- @options[:tag_run] ?
416
- @ticket_repository.save_to_file(historical_tag_file, current_scan_state) :
417
- @ticket_repository.save_to_file(historical_scan_file, current_scan_state)
418
- log_message('Historical CSV file generated.')
419
- else
420
- log_message('Obtaining last scan information.')
421
- @nexpose_item_histories = @ticket_repository.last_scans(@options)
422
-
423
- # Scan states can change during our processing. Store the state we are
424
- # about to process and move this to the historical file if we
425
- # successfully process.
426
- log_message('Calculated deltas, storing current scan state.')
427
- current_scan_state = ticket_repository.load_last_scans(options)
428
-
429
- # Only run if a scan has been ran ever in Nexpose.
430
- unless @nexpose_item_histories.empty?
431
- delta_site_report(@ticket_repository, @options, @helper, file_site_histories)
432
- # Processing completed successfully. Update historical scan file.
433
- @options[:tag_run] ?
434
- @ticket_repository.save_to_file(historical_tag_file, current_scan_state) :
435
- @ticket_repository.save_to_file(historical_scan_file, current_scan_state)
436
- end
437
- end
438
- log_message('Exiting ticket service.')
439
- end
440
- end
441
- end
1
+ module NexposeTicketing
2
+ #
3
+ # The Nexpose Ticketing service.
4
+ #
5
+ =begin
6
+
7
+ Copyright (C) 2014, Rapid7 LLC
8
+ All rights reserved.
9
+
10
+ Redistribution and use in source and binary forms, with or without modification,
11
+ are permitted provided that the following conditions are met:
12
+
13
+ * Redistributions of source code must retain the above copyright notice,
14
+ this list of conditions and the following disclaimer.
15
+
16
+ * Redistributions in binary form must reproduce the above copyright notice,
17
+ this list of conditions and the following disclaimer in the documentation
18
+ and/or other materials provided with the distribution.
19
+
20
+ * Neither the name of Rapid7 LLC nor the names of its contributors
21
+ may be used to endorse or promote products derived from this software
22
+ without specific prior written permission.
23
+
24
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
25
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
26
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
28
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
29
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
30
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
31
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
32
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
33
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
+
35
+ =end
36
+
37
+ #
38
+ # WARNING! This code makes an SSL connection to the Nexpose server, but does NOT
39
+ # verify the certificate at this time. This can be a security issue if
40
+ # an attacker is able to man-in-the-middle the connection between the
41
+ # Metasploit console and the Nexpose server. In the common case of
42
+ # running Nexpose and Metasploit on the same host, this is a low risk.
43
+ #
44
+
45
+ #
46
+ # WARNING! This code is still rough and going through substantive changes. While
47
+ # you can build tools using this library today, keep in mind that
48
+ # method names and parameters may change in the future.
49
+ #
50
+ class TicketService
51
+ require 'csv'
52
+ require 'yaml'
53
+ require 'fileutils'
54
+ require 'nexpose_ticketing/ticket_repository'
55
+ require 'nexpose_ticketing'
56
+ require 'nexpose_ticketing/nx_logger'
57
+ require 'nexpose_ticketing/version'
58
+
59
+ TICKET_SERVICE_CONFIG_PATH = File.join(File.dirname(__FILE__), '/config/ticket_service.config')
60
+ LOGGER_FILE = File.join(File.dirname(__FILE__), '/logs/ticket_service.log')
61
+
62
+ attr_accessor :helper_data, :nexpose_data, :options, :ticket_repository, :first_time
63
+
64
+ def setup(helper_data)
65
+ # Gets the Ticket Service configuration.
66
+ service_data = begin
67
+ YAML.load_file(TICKET_SERVICE_CONFIG_PATH)
68
+ rescue ArgumentError => e
69
+ raise "Could not parse YAML #{TICKET_SERVICE_CONFIG_PATH} : #{e.message}"
70
+ end
71
+
72
+ @helper_data = helper_data
73
+ @nexpose_data = service_data[:nexpose_data]
74
+ @options = service_data[:options]
75
+ @options[:file_name] = @options[:file_name].to_s
76
+ @options[:scan_mode] = get_scan_mode
77
+
78
+ #Temporary - this should be refactored out e.g. to include DAGs
79
+ @options[:tag_run] = @options[:scan_mode] == 'tag'
80
+
81
+ file_name = @options["#{@options[:scan_mode]}_file_name".to_sym]
82
+ @historical_file = File.join(File.dirname(__FILE__), file_name)
83
+
84
+ # Sets logging up, if enabled.
85
+ setup_logging(@options[:logging_enabled])
86
+
87
+ mode_class = load_class 'mode', @options[:ticket_mode]
88
+ @mode = mode_class.new(@options)
89
+ @options[:query_suffix] = @mode.get_query_suffix
90
+
91
+ helper_class = load_class 'helper', @helper_data[:helper_name]
92
+ @helper = helper_class.new(@helper_data, @options, @mode)
93
+
94
+ log_message("Creating ticketing repository with timeout value: #{@options[:timeout]}.")
95
+ @ticket_repository = NexposeTicketing::TicketRepository.new(options)
96
+ @ticket_repository.nexpose_login(@nexpose_data)
97
+ end
98
+
99
+ def load_class(type, name)
100
+ name.gsub!(type.capitalize, '')
101
+ path = "#{type}s/#{name}_#{type}.rb".downcase
102
+
103
+ log_message("Loading #{type} dependency: #{path}.")
104
+ begin
105
+ require_relative path
106
+ rescue => e
107
+ error = "#{type.capitalize} dependency '#{path}' could not be loaded."
108
+ @log.error e.to_s
109
+ @log.error error
110
+ fail error
111
+ end
112
+
113
+ eval("#{name}#{type.capitalize}")
114
+ end
115
+
116
+ def setup_logging(enabled = false)
117
+ helper_log = NexposeTicketing::NxLogger.instance
118
+ helper_log.setup_logging(@options[:logging_enabled],
119
+ @options[:log_level],
120
+ @options[:log_console])
121
+
122
+ return unless enabled
123
+ require 'logger'
124
+ directory = File.dirname(LOGGER_FILE)
125
+ FileUtils.mkdir_p(directory) unless File.directory?(directory)
126
+ @log = Logger.new(LOGGER_FILE, 'monthly')
127
+ @log.level = Logger::INFO
128
+ log_message('Logging enabled, starting service.')
129
+ end
130
+
131
+ # Logs a message if logging is enabled.
132
+ def log_message(message)
133
+ @log.info(message) if @options[:logging_enabled]
134
+ end
135
+
136
+ # Prepares all the local and nexpose historical data.
137
+ def prepare_historical_data(ticket_repository, options)
138
+ historical_scan_file = @historical_file
139
+
140
+ file_site_histories = nil
141
+ if File.exists?(historical_scan_file)
142
+ log_message("Reading historical CSV file: #{historical_scan_file}.")
143
+ file_site_histories = ticket_repository.read_last_scans(historical_scan_file)
144
+ end
145
+
146
+ file_site_histories
147
+ end
148
+
149
+ # Generates a full site(s) report ticket(s).
150
+ def all_site_report(ticket_repository, options)
151
+ group = "#{options[:scan_mode]}s"
152
+
153
+ log_message("Generating full vulnerability report on user entered #{group}.")
154
+ items_to_query = Array(options[group.to_sym])
155
+ log_message("Generating full vulnerability report on the following #{group}: #{items_to_query.join(', ')}")
156
+
157
+ items_to_query.each do |item|
158
+ log_message("Running full vulnerability report on item #{item}")
159
+ all_vulns_file = ticket_repository.all_new_vulns(options, item)
160
+ log_message('Preparing tickets.')
161
+
162
+ ticket_rate_limiter(all_vulns_file, 'create', item)
163
+
164
+ post_scan item
165
+ end
166
+
167
+ log_message('Finished processing all vulnerabilities.')
168
+ end
169
+
170
+ # There's possibly a new scan with new data.
171
+ def delta_site_report(ticket_repository, options, helper, scan_histories)
172
+ # Compares the scan information from file && Nexpose.
173
+ no_processing = true
174
+ @latest_scans.each do |item_id, last_scan_id|
175
+ # There's no entry in the file, so it's either a new item in Nexpose or a new item we have to monitor.
176
+ prev_scan_id = scan_histories[item_id]
177
+
178
+ if prev_scan_id.nil? || prev_scan_id == -1
179
+ options[:nexpose_item] = item_id
180
+ full_new_site_report(item_id, ticket_repository, options, helper)
181
+ options[:nexpose_item] = nil
182
+ post_scan item_id
183
+ no_processing = false
184
+ # Site has been scanned since last seen according to the file.
185
+ elsif prev_scan_id.to_s != @latest_scans[item_id].to_s
186
+ delta_new_scan(item_id, options, helper, scan_histories)
187
+ no_processing = false
188
+ end
189
+ end
190
+
191
+ log_name = @options["#{@options[:scan_mode]}_file_name".to_sym]
192
+ # Done processing, update the CSV to the latest scan info.
193
+ if no_processing
194
+ log_message("Nothing new to process, updating historical CSV file #{log_name}.")
195
+ else
196
+ log_message("Done processing, updating historical CSV file #{log_name}.")
197
+ end
198
+ no_processing
199
+ end
200
+
201
+ # There's a new site we haven't seen before.
202
+ def full_new_site_report(nexpose_item, ticket_repository, options, helper)
203
+ log_message("New nexpose id: #{nexpose_item} detected. Generating report.")
204
+ new_item_vuln_file = ticket_repository.all_new_vulns(options, nexpose_item)
205
+ log_message('Report generated, preparing tickets.')
206
+
207
+ nexpose_id = format_id(nexpose_item)
208
+ ticket_rate_limiter(new_item_vuln_file, 'create', nexpose_id)
209
+ end
210
+
211
+ def ticket_rate_limiter_processor(ticket_batch, ticket_method, nexpose_item)
212
+ #Just the header (no tickets).
213
+ if ticket_batch.size == 1
214
+ log_message('Received empty batch. Not sending tickets.')
215
+ return
216
+ end
217
+
218
+ nexpose_item = format_id(nexpose_item)
219
+
220
+ # Prep the batch of tickets
221
+ log_message("Preparing to #{ticket_method} tickets.")
222
+ tickets = @helper.send("prepare_#{ticket_method}_tickets",
223
+ ticket_batch.join(''),
224
+ nexpose_item)
225
+ log_message("Parsed rows: #{ticket_batch.size}")
226
+
227
+ # Send them off
228
+ log_message('Sending tickets.')
229
+ @helper.send("#{ticket_method}_tickets", tickets)
230
+ log_message('Returning for next batch.')
231
+ end
232
+
233
+ def ticket_rate_limiter(query_results_file, ticket_method, nexpose_item)
234
+ batch_size_max = @options[:batch_size]
235
+ log_message("Batching tickets in sizes: #{@options[:batch_size]}")
236
+ matching_fields = @mode.get_matching_fields
237
+ current_ids = Hash[*matching_fields.collect { |k| [k, nil] }.flatten]
238
+
239
+ # Start the batching
240
+ query_results_file.rewind
241
+ csv_header = query_results_file.readline
242
+ ticket_batch = []
243
+
244
+ begin
245
+ CSV.foreach(query_results_file, headers: csv_header) do |row|
246
+ ticket_batch << row
247
+
248
+ # Store the current
249
+ if ticket_batch.size < batch_size_max
250
+ matching_fields.each { |id| current_ids[id] = row[id] }
251
+ next
252
+ end
253
+
254
+ # Ensure that all rows associated with a ticket are captured.
255
+ # This potentially ignores the batch limit.
256
+ next if current_ids.all? { |id, val| val == row[id] }
257
+
258
+ #Last row is mismatch/independent
259
+ leftover_row = ticket_batch.pop
260
+
261
+ log_message('Batch size reached. Sending tickets.')
262
+ ticket_rate_limiter_processor(ticket_batch, ticket_method, nexpose_item)
263
+
264
+ ticket_batch.clear
265
+ ticket_batch << csv_header
266
+ ticket_batch << leftover_row
267
+ current_ids.each { |k,v| k = nil }
268
+ end
269
+ ensure
270
+ log_message('Finished reading report. Sending any remaining tickets and cleaning up file system.')
271
+
272
+ ticket_rate_limiter_processor(ticket_batch, ticket_method, nexpose_item)
273
+
274
+ query_results_file.close
275
+ query_results_file.unlink
276
+ end
277
+ end
278
+
279
+ def get_scan_mode
280
+ return 'tag' unless @options[:tags].nil? || @options[:tags].empty?
281
+ return 'site'
282
+ end
283
+
284
+ # Starts the Ticketing Service.
285
+ def start
286
+ # Checks if the csv historical file already exists and reads it, otherwise create it and assume first time run.
287
+ scan_histories = prepare_historical_data(@ticket_repository, @options)
288
+
289
+ # If we didn't specify a site || first time run (no scan history), then it gets all the vulnerabilities.
290
+ @options[:initial_run] = full_scan_required?(scan_histories)
291
+
292
+ if @options[:initial_run]
293
+ full_scan
294
+ else
295
+ delta_scan(scan_histories)
296
+ end
297
+
298
+ @helper.finish
299
+ log_message('Exiting ticket service.')
300
+ end
301
+
302
+ def full_scan
303
+ log_message('Storing current scan state before obtaining all vulnerabilities.')
304
+ current_scan_state = ticket_repository.load_last_scans(@options)
305
+
306
+ all_site_report(@ticket_repository, @options)
307
+
308
+ #Generate historical CSV file after completing the fist query.
309
+ log_message('No historical CSV file found. Generating.')
310
+ @ticket_repository.save_to_file(@historical_file, current_scan_state)
311
+ log_message('Historical CSV file generated.')
312
+ end
313
+
314
+ def delta_scan(scan_histories)
315
+ log_message('Obtaining last scan information.')
316
+ @latest_scans = @ticket_repository.last_scans(@options)
317
+
318
+ # Scan states can change during our processing. Store the state we are
319
+ # about to process and move this to the historical file if we
320
+ # successfully process.
321
+ log_message('Calculated deltas, storing current scan state.')
322
+ current_scan_state = ticket_repository.load_last_scans(@options)
323
+
324
+ # Only run if a scan has been ran ever in Nexpose.
325
+ return if @latest_scans.empty?
326
+
327
+ delta_site_report(@ticket_repository, @options, @helper, scan_histories)
328
+ # Processing completed successfully. Update historical scan file.
329
+ @ticket_repository.save_to_file(@historical_file, current_scan_state)
330
+ end
331
+
332
+ # Performs a delta scan
333
+ def delta_new_scan(item_id, options, helper, scan_histories)
334
+ delta_func = "delta_#{options[:scan_mode]}_new_scan"
335
+ self.send(delta_func, item_id, options, helper, scan_histories)
336
+ end
337
+
338
+ # There's a new scan with possibly new vulnerabilities.
339
+ def delta_site_new_scan(nexpose_item, options, helper, file_site_histories, tag_id=nil)
340
+ log_message("New scan detected for nexpose id: #{nexpose_item}. Generating report.")
341
+
342
+ format_method = "format_#{options[:scan_mode]}_id"
343
+ nexpose_id = self.send(format_method, tag_id || nexpose_item)
344
+
345
+ scan_options = { scan_id: file_site_histories[nexpose_item],
346
+ nexpose_item: nexpose_item,
347
+ severity: options[:severity],
348
+ ticket_mode: options[:ticket_mode],
349
+ riskScore: options[:riskScore],
350
+ vulnerabilityCategories: options[:vulnerabilityCategories],
351
+ tag_run: options[:tag_run],
352
+ tag: tag_id }
353
+
354
+ log_message("Scan id for new scan: #{file_site_histories[nexpose_item]}.")
355
+
356
+ if @mode.updates_supported?
357
+ helper_method = 'update'
358
+ query = 'all_vulns_since_scan'
359
+ else
360
+ helper_method = 'create'
361
+ query = 'new_vulns_since_scan'
362
+ end
363
+
364
+ all_scan_vuln_file = ticket_repository.send(query, scan_options)
365
+ ticket_rate_limiter(all_scan_vuln_file, helper_method, nexpose_id)
366
+
367
+ return unless options[:close_old_tickets_on_update] == 'Y'
368
+
369
+ tickets_to_close_file = ticket_repository.old_tickets(scan_options)
370
+ ticket_rate_limiter(tickets_to_close_file, 'close', nexpose_id)
371
+ end
372
+
373
+ def delta_tag_new_scan(nexpose_item, options, helper, file_site_histories, tag_id=nil)
374
+ # It's a tag run and something has changed (new/removed asset or new scan ID for an asset). To find out what, we must compare
375
+ # All tag assets and their scan IDs. Firstly we fetch all the assets in the tags
376
+ # in the configuration file and store them temporarily
377
+ item_id = nexpose_item
378
+ tag_assets_tmp_file = File.join(File.dirname(__FILE__), "/tag_assets/#{options[:tag_file_name]}_#{item_id}.tmp")
379
+ tag_assets_historic_file = File.join(File.dirname(__FILE__), "/tag_assets/#{options[:tag_file_name]}_#{item_id}.csv")
380
+ ticket_repository.generate_tag_asset_list(tags: item_id,
381
+ csv_file: tag_assets_tmp_file)
382
+ new_tag_configuration = ticket_repository.read_tag_asset_list(tag_assets_tmp_file)
383
+ historic_tag_config = ticket_repository.read_tag_asset_list(tag_assets_historic_file)
384
+ #Compare the assets within the tags and their scan histories to find the ones we need to query
385
+ changed_assets = Hash[*(historic_tag_config.to_a - new_tag_configuration.to_a).flatten]
386
+ new_assets = Hash[*(new_tag_configuration.to_a - historic_tag_config.to_a).flatten]
387
+ new_assets.delete_if {|asset_id, scan_id| historic_tag_config.has_key?(asset_id.to_s)}
388
+
389
+ #all_assets_changed = new_assets.merge(changed_assets)
390
+ changed_assets.each do |asset_id, scan_id|
391
+ delta_site_new_scan(asset_id, options,
392
+ helper, changed_assets, item_id)
393
+ end
394
+
395
+ new_assets.each do |asset_id, scan_id|
396
+ #Since no previous scan IDs - we generate a full report.
397
+ options[:nexpose_item] = asset_id
398
+ full_new_site_report(item_id, ticket_repository, options, helper)
399
+ options.delete(:nexpose_item)
400
+ end
401
+
402
+ #Update the historic file
403
+ new_tag_asset_list = historic_tag_config.merge(new_tag_configuration)
404
+ trimmed_csv = []
405
+ trimmed_csv << 'asset_id, last_scan_id'
406
+ new_tag_asset_list.each do |asset_id, last_scan_id|
407
+ trimmed_csv << "#{asset_id},#{last_scan_id}"
408
+ end
409
+ ticket_repository.save_to_file(tag_assets_historic_file, trimmed_csv)
410
+ File.delete(tag_assets_tmp_file)
411
+ end
412
+
413
+ # Methods to run after a scan
414
+ def post_scan(item_id)
415
+ self.send("post_#{@options[:scan_mode]}_scan", item_id)
416
+ end
417
+
418
+ def post_site_scan(item_id)
419
+ end
420
+
421
+ def post_tag_scan(item_id)
422
+ tag_assets_historic_file = File.join(File.dirname(__FILE__),
423
+ 'tag_assets',
424
+ "#{options[:tag_file_name]}_#{item_id}.csv")
425
+
426
+ ticket_repository.generate_tag_asset_list(tags: item_id,
427
+ csv_file: tag_assets_historic_file)
428
+ end
429
+
430
+ # Formats the Nexpose item ID according to the asset grouping mode
431
+ def format_id(item_id)
432
+ self.send("format_#{options[:scan_mode]}_id", item_id)
433
+ end
434
+
435
+ def format_site_id(item_id)
436
+ item_id
437
+ end
438
+
439
+ def format_tag_id(item_id)
440
+ "T#{item_id}"
441
+ end
442
+
443
+ # Determines whether all assets must be scanned
444
+ def full_scan_required?(histories)
445
+ self.send("full_#{@options[:scan_mode]}_scan_required?", histories)
446
+ end
447
+
448
+ def full_site_scan_required?(scan_histories)
449
+ is_full_run = false
450
+
451
+ if @options[:sites].nil? || @options[:sites].empty?
452
+ is_full_run = true
453
+
454
+ all_site_details = @ticket_repository.all_site_details
455
+ @options[:sites] = all_site_details.map { |s| s.id.to_s }
456
+
457
+ log_message("List of sites is now <#{@options[:sites]}>")
458
+ end
459
+
460
+ is_full_run || scan_histories.nil?
461
+ end
462
+
463
+ def full_tag_scan_required?(scan_histories)
464
+ if @options[:tags].nil? || @options[:tags].empty?
465
+ fail 'No tags specified within the configuration.'
466
+ end
467
+ return scan_histories.nil?
468
+ end
469
+ end
470
+ end