nexpose_ticketing 0.8.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,6 +4,8 @@ require 'net/https'
4
4
  require 'uri'
5
5
  require 'csv'
6
6
  require 'nexpose_ticketing/nx_logger'
7
+ require 'nexpose_ticketing/version'
8
+ require 'nexpose_ticketing/common_helper'
7
9
 
8
10
  # Serves as the ServiceNow interface for creating/updating issues from
9
11
  # vulnelrabilities found in Nexpose.
@@ -12,7 +14,8 @@ class ServiceNowHelper
12
14
  def initialize(servicenow_data, options)
13
15
  @servicenow_data = servicenow_data
14
16
  @options = options
15
- @log = NexposeTicketing::NXLogger.new
17
+ @log = NexposeTicketing::NxLogger.instance
18
+ @common_helper = NexposeTicketing::CommonHelper.new(@options)
16
19
  end
17
20
 
18
21
  # Sends a list of tickets (in JSON format) to ServiceNow individually (each ticket in the list
@@ -73,7 +76,7 @@ class ServiceNowHelper
73
76
  #
74
77
  def send_ticket(ticket, url, limit)
75
78
  raise ArgumentError, 'HTTP Redirect too deep' if limit == 0
76
-
79
+
77
80
  uri = URI.parse(url)
78
81
  headers = { 'Content-Type' => 'application/json',
79
82
  'Accept' => 'application/json' }
@@ -108,64 +111,23 @@ class ServiceNowHelper
108
111
  # - List of JSON-formated tickets for creating within ServiceNow.
109
112
  #
110
113
  def prepare_create_tickets(vulnerability_list, nexpose_identifier_id)
111
- @ticket = Hash.new(-1)
114
+ @log.log_message('Preparing ticket requests...')
112
115
  case @options[:ticket_mode]
113
- # 'D' Default mode: IP *-* Vulnerability
114
- when 'D'
115
- prepare_create_tickets_default(vulnerability_list, nexpose_identifier_id)
116
- # 'I' IP address mode: IP address -* Vulnerability
117
- when 'I'
118
- prepare_create_tickets_by_ip(vulnerability_list, nexpose_identifier_id)
116
+ # 'D' Default IP *-* Vulnerability
117
+ when 'D' then matching_fields = ['ip_address', 'vulnerability_id']
118
+ # 'I' IP address -* Vulnerability
119
+ when 'I' then matching_fields = ['ip_address']
120
+ # 'V' Vulnerability -* Assets
121
+ when 'V' then matching_fields = ['vulnerability_id']
119
122
  else
120
- fail 'No ticketing mode selected.'
123
+ fail 'Unsupported ticketing mode selected.'
121
124
  end
122
- end
123
-
124
-
125
- # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
126
- # ServiceNow. The preparation by default means that each vulnerability within Nexpose is a
127
- # separate incident within ServiceNow. This makes for smaller, more actionalble incidents but
128
- # could lead to a very large total number of incidents.
129
- #
130
- # * *Args* :
131
- # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
132
- #
133
- # * *Returns* :
134
- # - List of JSON-formated tickets for creating within ServiceNow.
135
- #
136
- def prepare_create_tickets_default(vulnerability_list, nexpose_identifier_id)
137
- @log.log_message('Preparing tickets by default method.')
138
- tickets = []
139
- CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
140
- # ServiceNow doesn't allow new line characters in the incident short description.
141
- summary = row['summary'].gsub(/\n/, ' ')
142
-
143
- @log.log_message("Creating ticket with IP address: #{row['ip_address']}, Nexpose identifier id: #{nexpose_identifier_id} and summary: #{summary}")
144
- # NXID in the u_work_notes is a unique identifier used to query incidents to update/resolve
145
- # incidents as they are resolved in Nexpose.
146
125
 
147
- ticket = {
148
- 'sysparm_action' => 'insert',
149
- 'u_caller_id' => "#{@servicenow_data[:username]}",
150
- 'u_category' => 'Software',
151
- 'u_impact' => '1',
152
- 'u_urgency' => '1',
153
- 'u_short_description' => "#{row['ip_address']} => #{summary}",
154
- 'u_work_notes' => "Summary: #{summary}
155
- Fix: #{row['fix']}
156
- ----------------------------------------------------------------------------
157
- URL: #{row['url']}
158
- NXID: #{nexpose_identifier_id}#{row['asset_id']}#{row['vulnerability_id']}#{row['solution_id']}"
159
- }.to_json
160
- tickets.push(ticket)
161
- end
162
- tickets
126
+ prepare_tickets(vulnerability_list, nexpose_identifier_id, matching_fields)
163
127
  end
164
128
 
165
129
  # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
166
- # ServiceNow. The preparation by IP means that all vulnerabilities within Nexpose for one IP
167
- # address are consolidated into a single ServiceNow incident. This reduces the number of incidents
168
- # within ServiceNow but greatly increases the size of the work notes.
130
+ # ServiceNow.
169
131
  #
170
132
  # * *Args* :
171
133
  # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
@@ -173,57 +135,65 @@ class ServiceNowHelper
173
135
  # * *Returns* :
174
136
  # - List of JSON-formated tickets for creating within ServiceNow.
175
137
  #
176
- def prepare_create_tickets_by_ip(vulnerability_list, nexpose_identifier_id)
177
- @log.log_message('Preparing tickets by IP address.')
138
+ def prepare_tickets(vulnerability_list, nexpose_identifier_id, matching_fields)
139
+ @ticket = Hash.new(-1)
140
+
141
+ @log.log_message("Preparing tickets in #{options[:ticket_mode]} address.")
178
142
  tickets = []
179
- current_ip = -1
143
+ previous_row = nil
144
+ description = nil
145
+ action = 'insert'
180
146
  CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
181
- if current_ip == -1
182
- current_ip = row['ip_address']
183
- @log.log_message("Creating ticket with IP address: #{row['ip_address']} for Nexpose identifier with ID: #{nexpose_identifier_id}")
147
+ if previous_row.nil?
148
+ previous_row = row.dup
149
+ nxid = @common_helper.generate_nxid(nexpose_identifier_id, row)
150
+ action = unless row['comparison'].nil? || row['comparison'] == 'New'
151
+ 'update'
152
+ else
153
+ 'insert'
154
+ end
184
155
  @ticket = {
185
- 'sysparm_action' => 'insert',
156
+ 'sysparm_action' => action,
186
157
  'u_caller_id' => "#{@servicenow_data[:username]}",
187
158
  'u_category' => 'Software',
188
159
  'u_impact' => '1',
189
160
  'u_urgency' => '1',
190
- 'u_short_description' => "#{row['ip_address']} => Vulnerabilities",
191
- 'u_work_notes' => "\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
192
- ++ New Vulnerabilities ++++++++++++++++++++++++++++++++++++
193
- +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
161
+ 'u_short_description' => @common_helper.get_title(row),
162
+ 'sysparm_query' => "active=true^u_work_notesCONTAINSNXID: #{nxid}",
163
+ 'u_work_notes' => ""
194
164
  }
195
- end
196
- if current_ip == row['ip_address']
197
- @ticket['u_work_notes'] +=
198
- "\n\n==========================================
199
- Summary: #{row['summary']}
200
- ----------------------------------------------------------------------------
201
- Fix: #{row['fix']}"
202
- unless row['url'].nil?
203
- @ticket['u_work_notes'] +=
204
- "\n----------------------------------------------------------------------------
205
- URL: #{row['url']}"
206
- end
207
- end
208
- unless current_ip == row['ip_address']
209
- # NXID in the u_work_notes is the unique identifier used to query incidents to update them.
210
- @log.log_message("Found new IP address. Finishing ticket with with IP address: #{current_ip} and moving onto IP #{row['ip_address']}")
211
- @ticket['u_work_notes'] += "\nNXID: #{nexpose_identifier_id}#{current_ip}"
212
- @ticket = @ticket.to_json
165
+ description = @common_helper.get_description(nexpose_identifier_id, row)
166
+ elsif matching_fields.any? { |x| previous_row[x].nil? || previous_row[x] != row[x] }
167
+ info = @common_helper.get_field_info(matching_fields, previous_row)
168
+ @log.log_message("Generated ticket with #{info}")
169
+
170
+ @ticket['u_work_notes'] = @common_helper.print_description(description)
213
171
  tickets.push(@ticket)
214
- current_ip = -1
172
+ previous_row = nil
173
+ description = nil
215
174
  redo
175
+ else
176
+ if !row['comparison'].nil? && row['comparison'] != 'New'
177
+ action = 'update'
178
+ end
179
+ description = @common_helper.update_description(description, row)
216
180
  end
217
181
  end
218
- # NXID in the u_work_notes is the unique identifier used to query incidents to update them.
219
- @ticket['u_work_notes'] += "\nNXID: #{nexpose_identifier_id}#{current_ip}" unless (@ticket.size == 0)
220
- tickets.push(@ticket.to_json) unless @ticket.nil?
221
- tickets
182
+
183
+ unless @ticket.nil? || @ticket.empty?
184
+ @ticket['u_work_notes'] = @common_helper.print_description(description) unless (@ticket.size == 0)
185
+ tickets.push(@ticket)
186
+ end
187
+ @log.log_message("Generated <#{tickets.count.to_s}> tickets.")
188
+
189
+ tickets.map do |t|
190
+ t.delete('sysparm_query') if t['sysparm_action'] == 'insert'
191
+ t.to_json
192
+ end
222
193
  end
223
-
224
- # Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose. This method
225
- # currently only supports updating IP-address mode tickets in ServiceNow. The list of vulnerabilities
226
- # are ordered by IP address and then by ticket_status, allowing the method to loop through and
194
+
195
+ # Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose. The list of vulnerabilities
196
+ # are ordered depending on the ticketing mode and then by ticket_status, allowing the method to loop through and
227
197
  # display new, old, and same vulnerabilities in that order.
228
198
  #
229
199
  # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
@@ -232,67 +202,18 @@ class ServiceNowHelper
232
202
  # - List of JSON-formated tickets for updating within ServiceNow.
233
203
  #
234
204
  def prepare_update_tickets(vulnerability_list, nexpose_identifier_id)
235
- fail 'Ticket updates are only supported in IP-address mode.' if @options[:ticket_mode] == 'D'
236
- @ticket = Hash.new(-1)
237
205
 
238
- @log.log_message('Preparing ticket updates by IP address.')
239
- tickets = []
240
- current_ip = -1
241
- ticket_status = 'New'
242
- CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
243
- if current_ip == -1
244
- current_ip = row['ip_address']
245
- ticket_status = row['comparison']
246
- @log.log_message("Creating ticket update with IP address: #{row['ip_address']} and Nexpose identifier ID: #{nexpose_identifier_id}")
247
- @log.log_message("Ticket status #{ticket_status}")
248
- action = 'update'
249
- if ticket_status == 'New'
250
- action = 'insert'
251
- end
252
- @ticket = {
253
- 'sysparm_action' => action,
254
- 'sysparm_query' => "u_work_notesCONTAINSNXID: #{nexpose_identifier_id}#{row['ip_address']}",
255
- 'u_work_notes' =>
256
- "\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
257
- ++ #{row['comparison']} Vulnerabilities +++++++++++++++++++++++++++++++++++++
258
- +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
259
- }
260
- end
261
- if current_ip == row['ip_address']
262
- # If the ticket_status is different, add a a new 'header' to signify a new block of tickets.
263
- unless ticket_status == row['comparison']
264
- @ticket['u_work_notes'] +=
265
- "\n\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
266
- ++ #{row['comparison']} Vulnerabilities +++++++++++++++++++++++++++++++++++++
267
- +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
268
- ticket_status = row['comparison']
269
- end
270
-
271
- @ticket['u_work_notes'] +=
272
- "\n\n==========================================
273
- Summary: #{row['summary']}
274
- ----------------------------------------------------------------------------
275
- Fix: #{row['fix']}"
276
- # Only add the URL block if data exists in the row.
277
- unless row['url'].nil?
278
- @ticket['u_work_notes'] +=
279
- "----------------------------------------------------------------------------
280
- URL: #{row['url']}"
281
- end
282
- end
283
- unless current_ip == row['ip_address']
284
- # NXID in the u_work_notes is the unique identifier used to query incidents to update them.
285
- @ticket['u_work_notes'] += "\nNXID: #{nexpose_identifier_id}#{current_ip}"
286
- @ticket = @ticket.to_json
287
- tickets.push(@ticket)
288
- current_ip = -1
289
- redo
290
- end
206
+ case @options[:ticket_mode]
207
+ when 'D' then fail 'Ticket updates are not supported in Default mode.'
208
+ # 'I' IP address -* Vulnerability
209
+ when 'I' then matching_fields = ['ip_address']
210
+ # 'V' Vulnerability -* Assets
211
+ when 'V' then matching_fields = ['vulnerability_id']
212
+ else
213
+ fail 'Unsupported ticketing mode selected.'
291
214
  end
292
- # NXID in the u_work_notes is the unique identifier used to query incidents to update them.
293
- @ticket['u_work_notes'] += "\nNXID: #{nexpose_identifier_id}#{current_ip}" unless (@ticket.size == 0)
294
- tickets.push(@ticket.to_json) unless @ticket.nil?
295
- tickets
215
+
216
+ prepare_tickets(vulnerability_list, nexpose_identifier_id, matching_fields)
296
217
  end
297
218
 
298
219
 
@@ -310,28 +231,16 @@ class ServiceNowHelper
310
231
  tickets = []
311
232
  @nxid = nil
312
233
  CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
313
- case @options[:ticket_mode]
314
- # 'D' Default mode: IP *-* Vulnerability
315
- when 'D'
316
- @nxid = "#{nexpose_identifier_id}#{row['asset_id']}#{row['vulnerability_id']}#{row['solution_id']}"
317
- # 'I' IP address mode: IP address -* Vulnerability
318
- when 'I'
319
- @nxid = "#{nexpose_identifier_id}#{row['ip_address']}"
320
- # 'V' Vulnerability mode: Vulnerability -* IP address
321
- ## when 'V'
322
- ## @nxid = "#{nexpose_identifier_id}#{row['asset_id']}#{row['vulnerability_id']}"
323
- else
324
- fail 'Could not close tickets - do not understand the ticketing mode!'
325
- end
234
+ @nxid = @common_helper.generate_nxid(nexpose_identifier_id, row)
326
235
  # 'state' 7 is the "Closed" state within ServiceNow.
327
236
  @log.log_message("Closing ticket with NXID: #{@nxid}.")
328
237
  ticket = {
329
238
  'sysparm_action' => 'update',
330
- 'sysparm_query' => "u_work_notesCONTAINSNXID: #{@nxid}",
239
+ 'sysparm_query' => "active=true^u_work_notesCONTAINSNXID: #{@nxid}",
331
240
  'state' => '7'
332
241
  }.to_json
333
242
  tickets.push(ticket)
334
243
  end
335
244
  tickets
336
245
  end
337
- end
246
+ end
@@ -1,41 +1,150 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'singleton'
5
+
1
6
  module NexposeTicketing
2
- class NXLogger
3
- TICKET_SERVICE_CONFIG_PATH = File.join(File.dirname(__FILE__), '/config/ticket_service.config')
4
- LOGGER_FILE = File.join(File.dirname(__FILE__), '/log/ticket_helper.log')
5
-
6
- attr_accessor :options
7
-
8
- def initialize
9
- service_data = begin
10
- YAML.load_file(TICKET_SERVICE_CONFIG_PATH)
11
- rescue ArgumentError => e
12
- raise "Could not parse YAML #{TICKET_SERVICE_CONFIG_PATH} : #{e.message}"
7
+ class NxLogger
8
+ include Singleton
9
+ attr_accessor :options, :statistic_key, :product, :logger_file
10
+ LOG_PATH = "./logs/rapid7_%s.log"
11
+ KEY_FORMAT = "external.integration.%s"
12
+ PRODUCT_FORMAT = "%s_%s"
13
+
14
+ DEFAULT_LOG = 'integration'
15
+ PRODUCT_RANGE = 3..30
16
+ KEY_RANGE = 3..15
17
+
18
+ ENDPOINT = '/data/external/statistic/'
19
+
20
+ def initialize()
21
+ @logger_file = get_log_path product
22
+ setup_logging(true, 'info')
23
+ end
24
+
25
+ def setup_statistics_collection(vendor, product_name, gem_version)
26
+ #Remove illegal characters
27
+ vendor.to_s.gsub!('-', '_')
28
+ product_name.to_s.gsub!('-', '_')
29
+
30
+ begin
31
+ @statistic_key = get_statistic_key vendor
32
+ @product = get_product product_name, gem_version
33
+ rescue => e
34
+ #Continue
13
35
  end
14
-
15
- @options = service_data[:options]
16
- setup_logging(@options[:logging_enabled])
17
- end
18
-
19
- def setup_logging(enabled = false)
20
- if enabled
21
- require 'logger'
22
- directory = File.dirname(LOGGER_FILE)
23
- FileUtils.mkdir_p(directory) unless File.directory?(directory)
24
- @log = Logger.new(LOGGER_FILE, 'monthly')
25
- @log.level = Logger::INFO
26
- log_message('Logging enabled for helper.')
36
+ end
37
+
38
+ def setup_logging(enabled, log_level = nil)
39
+ unless enabled || @log.nil?
40
+ log_message('Logging disabled.')
41
+ return
27
42
  end
43
+
44
+ @logger_file = get_log_path product
45
+
46
+ require 'logger'
47
+ directory = File.dirname(@logger_file)
48
+ FileUtils.mkdir_p(directory) unless File.directory?(directory)
49
+ io = IO.for_fd(IO.sysopen(@logger_file, 'a'))
50
+ io.autoclose = false
51
+ io.sync = true
52
+ @log = Logger.new(io, 'weekly')
53
+ @log.level = if log_level.casecmp('info') == 0
54
+ Logger::INFO
55
+ else
56
+ Logger::DEBUG
57
+ end
58
+ log_message("Logging enabled at level <#{log_level}>")
28
59
  end
29
60
 
30
- # Logs a message if logging is enabled.
61
+ # Logs an info message
31
62
  def log_message(message)
32
- @log.info(message) if @options[:logging_enabled]
63
+ @log.info(message) unless @log.nil?
64
+ end
65
+
66
+ # Logs a debug message
67
+ def log_debug_message(message)
68
+ @log.debug(message) unless @log.nil?
69
+ end
70
+
71
+ # Logs an error message
72
+ def log_error_message(message)
73
+ @log.error(message) unless @log.nil?
74
+ end
75
+
76
+ # Logs a warn message
77
+ def log_warn_message(message)
78
+ @log.warn(message) unless @log.nil?
79
+ end
80
+
81
+ def log_stat_message(message)
82
+ end
83
+
84
+ def get_log_path(product)
85
+ product.downcase! unless product.nil?
86
+ File.join(File.dirname(__FILE__), LOG_PATH % (product || DEFAULT_LOG))
87
+ end
88
+
89
+ def get_statistic_key(vendor)
90
+ if vendor.nil? || vendor.length < KEY_RANGE.min
91
+ log_stat_message("Vendor length is below minimum of <#{KEY_RANGE}>")
92
+ return nil
93
+ end
94
+
95
+ KEY_FORMAT % vendor[0...KEY_RANGE.max].downcase
33
96
  end
34
97
 
35
- # Logs a message if logging is enabled.
36
- def << (message)
37
- @log.info(message) if @options[:logging_enabled]
98
+ def get_product(product, version)
99
+ return nil if (product.nil? || version.nil?)
100
+ product = (PRODUCT_FORMAT % [product, version])[0...PRODUCT_RANGE.max]
101
+
102
+ if product.length < PRODUCT_RANGE.min
103
+ log_stat_message("Product length below minimum <#{PRODUCT_RANGE.min}>.")
104
+ return nil
105
+ end
106
+ product.downcase
107
+ end
108
+
109
+ def generate_payload(statistic_value='')
110
+ payload = {'statistic-key' => @statistic_key,
111
+ 'statistic-value' => statistic_value,
112
+ 'product' => @product}
113
+ JSON.generate(payload)
114
+ end
115
+
116
+ def send(nexpose_address, nexpose_port, session_id, payload)
117
+ header = {'Content-Type' => 'application/json',
118
+ 'nexposeCCSessionID' => session_id,
119
+ 'Cookie' => "nexposeCCSessionID=#{session_id}"}
120
+ req = Net::HTTP::Put.new(ENDPOINT, header)
121
+ req.body = payload
122
+ http_instance = Net::HTTP.new(nexpose_address, nexpose_port)
123
+ http_instance.use_ssl = true
124
+ http_instance.verify_mode = OpenSSL::SSL::VERIFY_NONE
125
+ response = http_instance.start { |http| http.request(req) }
126
+ log_stat_message "Received code #{response.code} from Nexpose console."
127
+ log_stat_message "Received message #{response.msg} from Nexpose console."
128
+ log_stat_message 'Finished sending statistics data to Nexpose.'
129
+ response.code
130
+ end
131
+
132
+ def on_connect(nexpose_address, nexpose_port, session_id, value)
133
+ log_stat_message 'Sending statistics data to Nexpose'
134
+
135
+ if @product.nil? || @statistic_key.nil?
136
+ log_stat_message('Invalid product name and/or statistics key.')
137
+ log_stat_message('Statistics collection not enabled.')
138
+ return
139
+ end
140
+
141
+ begin
142
+ payload = generate_payload value
143
+ send(nexpose_address, nexpose_port, session_id, payload)
144
+ rescue => e
145
+ #Let the program continue
146
+ end
38
147
  end
39
148
 
40
149
  end
41
- end
150
+ end