nexpose_ticketing 0.8.3 → 1.0.0

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.
@@ -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