nexpose_ticketing 1.0.2 → 1.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.
@@ -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