nexpose_ticketing 1.3.0 → 1.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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