nexpose_ticketing 1.3.0 → 1.4.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.
@@ -33,7 +33,9 @@
33
33
  # Timeout in seconds. The number of seconds the GEM waits for a response from Nexpose before exiting.
34
34
  :timeout: 10800
35
35
  # Ticket batching. Breaks ticket processing into groups of value size controlling resource utilisation of both systems.
36
- :batch_size: 200
36
+ :batch_size: 5000
37
+ # Ticket batching. Imposes a limit on the number of tickets created per batch.
38
+ :batch_ticket_limit: 50
37
39
  # (M) For 'I' & 'V' mode. Set to 'Y' for tickets that have been fixed to be closed after update, set to 'N' for ticket to be left
38
40
  # open for a user to manually close or change the status.
39
41
  :close_old_tickets_on_update: Y
@@ -7,167 +7,22 @@ require 'nexpose_ticketing/nx_logger'
7
7
  require 'nexpose_ticketing/version'
8
8
  require_relative './base_helper'
9
9
  require 'securerandom'
10
+ require 'typhoeus'
10
11
 
11
12
  # Serves as the ServiceNow interface for creating/updating issues from
12
- # vulnelrabilities found in Nexpose.
13
+ # vulnerabilities found in Nexpose.
13
14
  class ServiceNowHelper < BaseHelper
15
+
16
+ NEW_STATE = 1
17
+ RESOLVED_STATE = 6
18
+ CLOSED_STATE = 7
19
+
14
20
  attr_accessor :log, :transform
15
21
  def initialize(service_data, options, mode)
16
22
  super(service_data, options, mode)
17
23
  end
18
24
 
19
- # Sends a list of tickets (in JSON format) to ServiceNow individually (each ticket in the list
20
- # as a separate HTTP post).
21
- #
22
- # * *Args* :
23
- # - +tickets+ - List of JSON-formatted ticket creates (new tickets).
24
- #
25
- def create_tickets(tickets)
26
- fail 'Ticket(s) cannot be empty' if tickets.nil? || tickets.empty?
27
-
28
- tickets.each do |ticket|
29
- send_ticket(ticket, @service_data[:servicenow_url], @service_data[:redirect_limit])
30
- end
31
- end
32
-
33
- # Sends ticket updates (in JSON format) to ServiceNow individually (each ticket in the list as a
34
- # separate HTTP post).
35
- #
36
- # * *Args* :
37
- # - +tickets+ - List of JSON-formatted ticket updates.
38
- #
39
- def update_tickets(tickets)
40
- if tickets.nil? || tickets.empty?
41
- @log.log_message('No tickets to update.')
42
- return
43
- end
44
- tickets.each do |ticket|
45
- send_ticket(ticket, @service_data[:servicenow_url], @service_data[:redirect_limit])
46
- end
47
- end
48
-
49
- # Sends ticket closure (in JSON format) to ServiceNow individually (each ticket in the list as a
50
- # separate HTTP post).
51
- #
52
- # * *Args* :
53
- # - +tickets+ - List of JSON-formatted ticket closures.
54
- #
55
- def close_tickets(tickets)
56
- if tickets.nil? || tickets.empty?
57
- @log.log_message('No tickets to close.')
58
- return
59
- end
60
- @metrics.closed tickets.count
61
-
62
- tickets.each do |ticket|
63
- send_ticket(ticket, @service_data[:servicenow_url], @service_data[:redirect_limit])
64
- end
65
- end
66
-
67
- # Retrieves the unique ticket identifier for a particular NXID if one exists.
68
- #
69
- # * *Args* :
70
- # - +nxid+ - NXID of the ticket to be updated.
71
- #
72
- def get_ticket_identifier(nxid)
73
- headers = { 'Content-Type' => 'application/json',
74
- 'Accept' => 'application/json' }
75
-
76
- #Get the address
77
- query = "incident.do?JSONv2&sysparm_query=active=true^u_nxid=#{nxid}"
78
- uri = URI.join(@service_data[:servicenow_url], '/')
79
- full_url = URI.join(uri, "/").to_s + query
80
- req = Net::HTTP::Get.new(full_url, headers)
81
- req.basic_auth @service_data[:username], @service_data[:password]
82
- resp = Net::HTTP.new(uri.host, uri.port)
83
-
84
- # Enable this line for debugging the https call.
85
- # resp.set_debug_output(@log)
86
-
87
- if uri.scheme == 'https'
88
- resp.use_ssl = true
89
- resp.verify_mode = OpenSSL::SSL::VERIFY_NONE
90
- end
91
-
92
- begin
93
- retries ||= 0
94
- response = resp.request(req)
95
- rescue Exception => e
96
- @log.log_error_message("Request failed for NXID #{nxid}.\n#{e}. Retry #{retries}")
97
- retry if (retries += 1) < 3
98
- return
99
- end
100
-
101
- tickets = JSON.parse(response.body)
102
- records = tickets['records']
103
- if records.count > 1
104
- @log.log_error_message("Found more than one result for NXID #{nxid}. Updating first result.")
105
- records.each { |r| @log.log_error_message("NXID #{nxid} found with Rapid7 Identifier #{r['u_rpd_id']}") }
106
- elsif records.count == 0
107
- @log.log_error_message("No results found for NXID #{nxid}.")
108
- return nil
109
- end
110
-
111
- ticket_id = records.first['u_rpd_id']
112
- @log.log_message("Found ticket for NXID #{nxid} ID is: #{ticket_id}")
113
- if ticket_id.nil?
114
- @log.log_error_message("ID is nil for ticket with NXID #{nxid}.")
115
- end
116
-
117
- ticket_id
118
- end
119
-
120
- # Post an individual JSON-formatted ticket to ServiceNow. If the response from the post is a 301/
121
- # 302 redirect, the method will attempt to resend the ticket to the response's location for up to
122
- # [limit] times (which starts at the redirect_limit config value and is decremented with each
123
- # redirect response.
124
- #
125
- # * *Args* :
126
- # - +ticket+ - JSON-formatted ticket.
127
- # - +url+ - URL to post the ticket to.
128
- # - +limit+ - The amount of times to retry the send ticket request before failing.l
129
- #
130
- def send_ticket(ticket, url, limit)
131
- raise ArgumentError, 'HTTP Redirect too deep' if limit == 0
132
-
133
- uri = URI.parse(url)
134
- headers = { 'Content-Type' => 'application/json',
135
- 'Accept' => 'application/json' }
136
- req = Net::HTTP::Post.new(url, headers)
137
- req.basic_auth @service_data[:username], @service_data[:password]
138
- req.body = ticket
139
-
140
- resp = Net::HTTP.new(uri.host, uri.port)
141
- # Setting verbose_mode to 'Y' will debug the https call(s).
142
- resp.set_debug_output $stderr if @service_data[:verbose_mode] == 'Y'
143
- resp.use_ssl = true if uri.scheme == 'https'
144
- # Currently, we do not verify SSL certificates (in case the local servicenow instance uses
145
- # and unsigned or expired certificate)
146
- resp.verify_mode = OpenSSL::SSL::VERIFY_NONE
147
- res = resp.start { |http| http.request(req) }
148
- case res
149
- when Net::HTTPSuccess then res
150
- when Net::HTTPRedirection then send_ticket(ticket, res['location'], limit - 1)
151
- else
152
- @log.log_message("Error in response: #{res['error']}")
153
- raise ArgumentError, res['error']
154
- end
155
- end
156
-
157
- # Prepare tickets from the CSV of vulnerabilities exported from Nexpose. This method determines
158
- # how to prepare the tickets (either by default or by IP address) based on config options.
159
- #
160
- # * *Args* :
161
- # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
162
- #
163
- # * *Returns* :
164
- # - List of JSON-formated tickets for creating within ServiceNow.
165
- #
166
- def prepare_create_tickets(vulnerability_list, nexpose_identifier_id)
167
- prepare_tickets(vulnerability_list, nexpose_identifier_id)
168
- end
169
-
170
- # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
25
+ # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
171
26
  # ServiceNow.
172
27
  #
173
28
  # * *Args* :
@@ -181,12 +36,12 @@ class ServiceNowHelper < BaseHelper
181
36
  matching_fields = @mode_helper.get_matching_fields
182
37
  @ticket = Hash.new(-1)
183
38
 
184
- @log.log_message("Preparing tickets in #{options[:ticket_mode]} mode.")
39
+ @log.log_message("Preparing tickets in #{options[:ticket_mode]} mode format.")
185
40
  tickets = []
186
41
  previous_row = nil
187
42
  description = nil
188
- action = 'insert'
189
-
43
+ action = 'insert'
44
+
190
45
  CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
191
46
  if previous_row.nil?
192
47
  previous_row = row.dup
@@ -200,12 +55,12 @@ class ServiceNowHelper < BaseHelper
200
55
 
201
56
  @ticket = {
202
57
  'sysparm_action' => action,
203
- 'caller_id' => "#{@service_data[:username]}",
204
- 'category' => 'Software',
205
- 'impact' => '1',
206
- 'urgency' => '1',
207
- 'short_description' => @mode_helper.get_title(row),
208
- 'work_notes' => "",
58
+ 'u_caller_id' => "#{@service_data[:username]}",
59
+ 'u_category' => 'Software',
60
+ 'u_impact' => '1',
61
+ 'u_urgency' => '1',
62
+ 'u_short_description' => @mode_helper.get_title(row),
63
+ 'u_work_notes' => "",
209
64
  'u_nxid' => nxid,
210
65
  'u_rpd_id' => nil
211
66
  }
@@ -214,44 +69,74 @@ class ServiceNowHelper < BaseHelper
214
69
  info = @mode_helper.get_field_info(matching_fields, previous_row)
215
70
  @log.log_message("Generated ticket with #{info}")
216
71
 
217
- @ticket['work_notes'] = @mode_helper.print_description(description)
72
+ @ticket['u_work_notes'] = @mode_helper.print_description(description)
218
73
  tickets.push(@ticket)
219
-
74
+
220
75
  previous_row = nil
221
76
  description = nil
222
77
  redo
223
78
  else
224
79
  unless row['comparison'].nil? || row['comparison'] == 'New'
225
80
  @ticket['sysparm_action'] = 'update'
226
- end
227
- description = @mode_helper.update_description(description, row)
81
+ end
82
+ description = @mode_helper.update_description(description, row)
228
83
  end
229
84
  end
230
85
 
231
86
  unless @ticket.nil? || @ticket.empty?
232
87
  info = @mode_helper.get_field_info(matching_fields, previous_row)
233
88
  @log.log_message("Generated ticket with #{info}")
234
- @ticket['work_notes'] = @mode_helper.print_description(description) unless (@ticket.size == 0)
89
+ @ticket['u_work_notes'] = @mode_helper.print_description(description) unless (@ticket.size == 0)
235
90
  tickets.push(@ticket)
236
91
  end
237
92
  @log.log_message("Generated <#{tickets.count.to_s}> tickets.")
238
93
 
239
- tickets.map do |t|
240
- if t['sysparm_action'] == 'update'
241
- t['sysparm_action'] = 'insert'
242
- t['u_rpd_id'] = get_ticket_identifier(t['u_nxid'])
243
- @metrics.updated
244
- else
245
- @metrics.created
246
- end
94
+ tickets
95
+ end
247
96
 
248
- t['u_rpd_id'] ||= SecureRandom.uuid
249
- t.to_json
97
+ # Prepare tickets from the CSV of vulnerabilities exported from Nexpose. This method determines
98
+ # how to prepare the tickets (either by default or by IP address) based on config options.
99
+ #
100
+ # * *Args* :
101
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
102
+ #
103
+ # * *Returns* :
104
+ # - List of JSON-formated tickets for creating within ServiceNow.
105
+ #
106
+ def prepare_create_tickets(vulnerability_list, nexpose_identifier_id)
107
+ prepare_tickets(vulnerability_list, nexpose_identifier_id)
108
+ end
109
+
110
+ # Sends a list of tickets (in JSON format) to ServiceNow individually (each ticket in the list
111
+ # as a separate HTTP post).
112
+ #
113
+ # * *Args* :
114
+ # - +tickets+ - List of JSON-formatted ticket creates (new tickets).
115
+ #
116
+ def create_tickets(tickets)
117
+ fail 'Ticket(s) cannot be empty' if tickets.nil? || tickets.empty?
118
+ final_ticket = tickets.count - 1
119
+ ticket_index = 0
120
+
121
+ hydra = Typhoeus::Hydra.new
122
+ requests = tickets.map do |ticket|
123
+ ticket['u_rpd_id'] = SecureRandom.uuid
124
+ request = generate_post_request(ticket.to_json,
125
+ ticket_index == final_ticket)
126
+ hydra.queue request
127
+ ticket_index += 1
128
+ request
250
129
  end
130
+
131
+ hydra.run
132
+
133
+ @metrics.created tickets.count
134
+ @log.log_message('Created ticket batch.')
135
+ requests.map(&:response)
251
136
  end
252
137
 
253
- # Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose. The list of vulnerabilities
254
- # are ordered depending on the ticketing mode and then by ticket_status, allowing the method to loop through and
138
+ # Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose. The list of vulnerabilities
139
+ # are ordered depending on the ticketing mode and then by ticket_status, allowing the method to loop through and
255
140
  # display new, old, and same vulnerabilities in that order.
256
141
  #
257
142
  # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
@@ -263,6 +148,170 @@ class ServiceNowHelper < BaseHelper
263
148
  prepare_tickets(vulnerability_list, nexpose_identifier_id)
264
149
  end
265
150
 
151
+ # Sends ticket updates (in JSON format) to ServiceNow by placing each request
152
+ # on a Typhoeus queue (each ticket in the list as a separate HTTP post).
153
+ #
154
+ # * *Args* :
155
+ # - +tickets+ - List of Hash-formatted ticket updates.
156
+ #
157
+ def update_tickets(tickets)
158
+ if tickets.nil? || tickets.empty?
159
+ @log.log_message('No tickets to update.')
160
+ return
161
+ end
162
+
163
+ requests = []
164
+ final_ticket = tickets.count - 1
165
+
166
+ hydra = Typhoeus::Hydra.new
167
+ tickets.each_with_index do |ticket, i|
168
+ if ticket['sysparm_action'] == 'update'
169
+ id_request = generate_identifier_request(ticket['u_nxid'])
170
+ id_request.on_complete do |response|
171
+ ticket['sysparm_action'] = 'insert'
172
+
173
+ current_data = parse_identifier_response(response, ticket['u_nxid'])
174
+ u_rpd_id = current_data[:id]
175
+ ticket['u_rpd_id'] = u_rpd_id || SecureRandom.uuid
176
+
177
+ if current_data[:state] == RESOLVED_STATE
178
+ ticket['u_state'] = NEW_STATE
179
+ title = "(Reopened) #{ticket['u_short_description']}"
180
+ ticket['u_short_description'] = title
181
+ current_notes = ticket['u_work_notes'].rpartition("\n\n\nNXID: ")
182
+ new_notes = '++ Reopened by Nexpose Ticketing Gem ' \
183
+ "++\n#{current_notes.first}"
184
+ nxid = current_notes[1,2].join('').lstrip
185
+ description = @mode_helper.finalize_description(new_notes, nxid)
186
+ ticket['u_work_notes'] = description
187
+ end
188
+
189
+ ticket_request = generate_post_request(ticket.to_json,
190
+ i == final_ticket)
191
+ hydra.queue ticket_request
192
+ ticket['u_rpd_id'] == u_rpd_id ? @metrics.updated : @metrics.created
193
+ requests << ticket_request
194
+ end
195
+
196
+ hydra.queue id_request
197
+ elsif ticket['sysparm_action'] == 'insert'
198
+ ticket['u_rpd_id'] ||= SecureRandom.uuid
199
+ ticket_request = generate_post_request(ticket.to_json,
200
+ i == final_ticket)
201
+ hydra.queue ticket_request
202
+ @metrics.created
203
+ requests << ticket_request
204
+ end
205
+ end
206
+
207
+ hydra.run
208
+ @log.log_message('Updated ticket batch.')
209
+ requests.map(&:response)
210
+ end
211
+
212
+
213
+
214
+ # Method generates a HTTP POST request containing the provided ticket to
215
+ # send to ServiceNow. Provides error handling via on_complete functionality
216
+ #
217
+ # * *Args* :
218
+ # - +ticket+ - The ticket to be sent to ServiceNow
219
+ # - +forbid_connection_reuse+ - Whether the current HTTP connection can be
220
+ # reused to send tickets to ServiceNow.
221
+ #
222
+ # * *Returns* :
223
+ # - A HTTP post request object to be placed on the queue for sending
224
+ #
225
+ def generate_post_request(ticket, forbid_connection_reuse)
226
+ request = generate_ticket_request(ticket, forbid_connection_reuse)
227
+ request.on_complete do |response|
228
+ unless response.success?
229
+ msg = if response.timed_out?
230
+ 'Time out has occurred.'
231
+ elsif response.code == 0
232
+ response.return_message
233
+ else
234
+ "HTTP request failed: #{response.code}"
235
+ end
236
+
237
+ @log.log_error_message msg
238
+ raise msg
239
+ end
240
+ end
241
+ request
242
+ end
243
+
244
+ # Method generates a HTTP POST request containing the provided ticket to
245
+ # send to ServiceNow. Provides error handling via on_complete functionality
246
+ #
247
+ # * *Args* :
248
+ # - +ticket+ - The ticket to be sent to ServiceNow
249
+ # - +forbid_connection_reuse+ - Whether the current HTTP connection can be
250
+ # reused to send tickets to ServiceNow.
251
+ #
252
+ # * *Returns* :
253
+ # - A HTTP request object to be placed on the queue for sending
254
+ #
255
+ def generate_ticket_request(ticket, forbid_connection_reuse)
256
+ address = @service_data[:servicenow_url]
257
+ userpwd = "#{@service_data[:username]}:#{@service_data[:password]}"
258
+ headers = { 'Content-Type' => 'application/json' }
259
+
260
+ options = {
261
+ method: :post,
262
+ userpwd: userpwd,
263
+ headers: headers,
264
+ accept_encoding: 'application/json',
265
+ maxredirs: @service_data[:redirect_limit],
266
+ ssl_verifyhost: 0,
267
+ forbid_reuse: forbid_connection_reuse,
268
+ body: ticket
269
+ }
270
+
271
+ Typhoeus::Request.new(address, options)
272
+ end
273
+
274
+ def generate_identifier_request(nxid)
275
+ query = "incident.do?JSONv2&sysparm_query=active=true^u_nxid=#{nxid}"
276
+ url = URI.join(@service_data[:servicenow_url], "/").to_s + query
277
+ userpwd = "#{@service_data[:username]}:#{@service_data[:password]}"
278
+ headers = { 'Content-Type' => 'application/json' }
279
+ options = {
280
+ method: :get,
281
+ userpwd: userpwd,
282
+ headers: headers,
283
+ accept_encoding: 'application/json',
284
+ maxredirs: @service_data[:redirect_limit],
285
+ ssl_verifyhost: 0
286
+ }
287
+
288
+ Typhoeus::Request.new(url, options)
289
+ end
290
+
291
+ def parse_identifier_response(response, nxid)
292
+ tickets = JSON.parse(response.body)
293
+ records = tickets['records']
294
+
295
+ if records.count > 1
296
+ @log.log_error_message("Found more than one result for NXID #{nxid}. " \
297
+ 'Updating first result.')
298
+ records.each { |r| @log.log_error_message("NXID #{nxid} found with " \
299
+ "Rapid7 Identifier #{r['u_rpd_id']}") }
300
+ elsif records.count == 0
301
+ @log.log_error_message("No results found for NXID #{nxid}.")
302
+ return { id: nil, state: nil }
303
+ end
304
+
305
+ ticket_id = records.first['u_rpd_id']
306
+ state = records.first['state']
307
+ @log.log_message("Found ticket for NXID #{nxid} ID is: #{ticket_id}")
308
+ if ticket_id.nil?
309
+ @log.log_error_message("ID is nil for ticket with NXID #{nxid}.")
310
+ state = nil
311
+ end
312
+
313
+ { id: ticket_id, state: state }
314
+ end
266
315
 
267
316
  # Prepare ticket closures from the CSV of vulnerabilities exported from Nexpose. This method
268
317
  # currently only supports updating default mode tickets in ServiceNow.
@@ -275,20 +324,55 @@ class ServiceNowHelper < BaseHelper
275
324
  #
276
325
  def prepare_close_tickets(vulnerability_list, nexpose_identifier_id)
277
326
  @log.log_message("Preparing ticket closures for mode #{@options[:ticket_mode]}.")
278
- tickets = []
279
- @nxid = nil
327
+ requests = {}
328
+
280
329
  CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
281
- @nxid = @mode_helper.get_nxid(nexpose_identifier_id, row)
282
- # 'state' 7 is the "Closed" state within ServiceNow.
283
- ticket_id = get_ticket_identifier(@nxid)
284
- @log.log_message("Closing ticket with NXID: #{@nxid}.")
285
- ticket = {
286
- 'sysparm_action' => 'insert',
287
- 'u_rpd_id' => ticket_id,
288
- 'state' => '7'
289
- }.to_json
290
- tickets.push(ticket)
330
+ nxid = @mode_helper.get_nxid(nexpose_identifier_id, row)
331
+ @log.log_message("Closing ticket with NXID: #{nxid}.")
332
+ request = generate_identifier_request(nxid)
333
+ requests[nxid] = request
291
334
  end
292
- tickets
335
+
336
+ requests
337
+ end
338
+
339
+ # Sends ticket closure (in JSON format) to ServiceNow individually
340
+ # (each ticket in the list as a separate HTTP post).
341
+ #
342
+ # * *Args* :
343
+ # - +requests+ - Hash containing NXIDs and associated Typheous requests.
344
+ #
345
+ def close_tickets(nxid_requests)
346
+ if nxid_requests.nil? || nxid_requests.empty?
347
+ @log.log_message('No tickets to close.')
348
+ return
349
+ end
350
+
351
+ ticket = {
352
+ 'sysparm_action' => 'insert',
353
+ 'u_rpd_id' => nil,
354
+ 'u_state' => CLOSED_STATE
355
+ }
356
+
357
+ requests = []
358
+ final_ticket = nxid_requests.count - 1
359
+
360
+ hydra = Typhoeus::Hydra.new
361
+ nxid_requests.each_with_index do |(nxid, request), i|
362
+ request.on_complete do |response|
363
+ u_rpd_id = parse_identifier_response(response, nxid)[:id]
364
+ ticket['u_rpd_id'] = u_rpd_id
365
+ ticket_request = generate_post_request(ticket.to_json,
366
+ i == final_ticket)
367
+ hydra.queue ticket_request
368
+ requests << ticket_request
369
+ end
370
+ hydra.queue request
371
+ end
372
+
373
+ hydra.run
374
+ @metrics.closed requests.count
375
+ @log.log_message('Closed ticket batch.')
376
+ requests.map(&:response)
293
377
  end
294
378
  end