Class: JiraHelper

Inherits:
BaseHelper show all
Defined in:
lib/nexpose_ticketing/helpers/jira_helper.rb

Overview

This class serves as the JIRA interface that creates issues within JIRA from vulnerabilities found in Nexpose.

Copyright

Copyright © 2014 Rapid7, LLC.

Instance Attribute Summary

Attributes inherited from BaseHelper

#options, #service_data

Instance Method Summary collapse

Methods inherited from BaseHelper

#finish, #load_dependencies

Constructor Details

#initialize(service_data, options, mode) ⇒ JiraHelper

Returns a new instance of JiraHelper.



15
16
17
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 15

def initialize(service_data, options, mode)
  super(service_data, options, mode)
end

Instance Method Details

#close_tickets(tickets) ⇒ Object

Sends ticket closure (in JSON format) to Jira individually (each ticket in the list as a separate web service call).

  • Args :

    • tickets - List of Jira ticket Keys to be closed.



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 256

def close_tickets(tickets)
  if tickets.nil? || tickets.empty?
    @log.log_message('No tickets to close.')
    return
  end
  closed_count = 0

  headers = { 'Content-Type' => 'application/json',
              'Accept' => 'application/json' }

  tickets.each do |ticket|
    uri = URI.parse(("#{@service_data[:jira_url]}#{ticket}/transitions"))
    req = Net::HTTP::Post.new(uri.to_s, headers)

    transition = get_jira_transition_details(ticket, @service_data[:close_step_id])
    if transition.nil?
      #Valid transition could not be found. Ignore ticket since we do not know what to do with it.
      @log.log_message("No valid transition found for ticket <#{ticket}>. Skipping closure.")
      next
    end

    #We need to find any required fields to send with the transition request
    required_fields = []
    transition['fields'].each do |field|
      next unless field[1]['required'] == true
        
      # Currently only required fields with 'allowedValues' in the JSON response are supported.
      if not field[1].has_key? 'allowedValues'
        @log.log_message("Closing ticket <#{ticket}> requires a field I know nothing about! Transition details are <#{transition}>. Ignoring this field.")
          next
      end
      val = "{\"id\" : \"#{field[1]['allowedValues'][0]['id']}\"}"
      val = "[#{field}]" if field[1]['schema']['type'] == 'array'
      required_fields <<  "\"#{field[0]}\" : #{val}"
    end

    req.body = "{\"transition\" : {\"id\" : #{transition['id']}}, \"fields\" : { #{required_fields.join(",")}}}"
    response = send_ticket(uri, req)
    code = response.nil? ? 1 : response.code.to_i
    break if code.between?(400, 499)

    closed_count += 1
  end

  @metrics.closed closed_count
end

#create_tickets(tickets) ⇒ Object



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 176

def create_tickets(tickets)
  fail 'Ticket(s) cannot be empty.' if tickets.nil? || tickets.empty?
  created_tickets = 0

  tickets.each do |ticket|
    headers = { 'Content-Type' => 'application/json',
                'Accept' => 'application/json' }

    uri = URI.parse("#{@service_data[:jira_url]}")

    req = Net::HTTP::Post.new(uri, headers)
    req.body = ticket
    
    response = send_ticket(uri, req)
    code = response.nil? ? 1 : response.code.to_i
    break if code.between?(400, 499)

    created_tickets += 1
  end

  @metrics.created created_tickets
end

#get_jira_key(jql_query, nxid = nil) ⇒ Object

Fetches the Jira ticket key e.g INT-1. This is required to post updates to the Jira.

  • Args :

    • JQL query string - Jira’s Query Language string used to search for a ticket key.

  • Returns :

    • Jira ticket key if found, nil otherwise.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 27

def get_jira_key(jql_query, nxid = nil)
  fail 'JQL query string cannot be empty.' if jql_query.empty?
  headers = { 'Content-Type' => 'application/json',
              'Accept' => 'application/json' }

  uri = URI.parse(("#{@service_data[:jira_url]}".split("/")[0..-2].join('/') + '/search'))
  uri.query = [uri.query, URI.escape(jql_query)].compact.join('&')
  req = Net::HTTP::Get.new(uri.to_s, headers)
  response = send_jira_request(uri, req)

  issues = JSON.parse(response.body)['issues']

  if issues.nil? || !issues.any?
    @log.log_message "JIRA did not return any keys for query containing NXID #{nxid}"
    return nil
  end

  if issues.size > 1
    # If Jira returns more than one key for a "unique" NXID query result then something has gone wrong...
    # Safest response is to return no key and let logic elsewhere dictate the action to take.
    error = "Jira returned multiple keys for query containing NXID #{nxid}."
    error += " Please check project within JIRA."
    error += " Response was <#{issues}>"
    @log.log_error_message(error)
    return nil
  end

 issues[0]['key']
end

#get_jira_transition_details(jira_key, step_id) ⇒ Object

Fetches the Jira ticket transition details for the given Jira ticket key. Tries to match the response to the the desired transition in the configuration file.

  • Args :

    • Jira key - Jira ticket key e.g. INT-1.

    • Step ID - Jira transition step id (Jira number assigned to a status).

  • Returns :

    • Jira transition details in JSON format if matched, nil otherwise.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 152

def get_jira_transition_details(jira_key, step_id)
  fail 'Jira ticket key and transition step ID required to find transition details.' if jira_key.nil? || step_id.nil?

  headers = { 'Content-Type' => 'application/json',
              'Accept' => 'application/json' }

  uri = URI.parse(("#{@service_data[:jira_url]}#{jira_key}/transitions?expand=transitions.fields."))
  req = Net::HTTP::Get.new(uri.to_s, headers)
  response = send_jira_request(uri, req)

  transitions = JSON.parse(response.body)

  if transitions.has_key? 'transitions'
    transitions['transitions'].each do |transition|
      if transition['to']['id'] == step_id.to_s
        return transition
      end
    end
  end
  error = "Response was <#{transitions}> and desired close Step ID was <#{@service_data[:close_step_id]}>. Jira returned no valid transition to close the ticket!"
  @log.log_message(error)
  return nil
end

#prepare_close_tickets(vulnerability_list, nexpose_identifier_id) ⇒ Object

Prepare ticket closures from the CSV of vulnerabilities exported from Nexpose.

  • Args :

    • vulnerability_list - CSV of vulnerabilities within Nexpose.

  • Returns :

    • List of Jira ticket Keys to be closed.



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 311

def prepare_close_tickets(vulnerability_list, nexpose_identifier_id)
  @log.log_message('Preparing tickets to close.')
  @nxid = nil
  tickets = []
  CSV.parse(vulnerability_list.chomp, headers: :first_row)  do |row|
    @nxid = @mode_helper.get_nxid(nexpose_identifier_id, row)
    # Query Jira for the ticket by unique id (generated NXID)
    query_string = "jql=project=#{@service_data[:project]} AND description ~ \"NXID: #{@nxid}\" AND (status != #{@service_data[:close_step_name]})&fields=key"
    queried_key = get_jira_key(query_string, @nxid)
    if queried_key.nil? || queried_key.empty?
      @log.log_message("Error when closing tickets - query for NXID <#{@nxid}> should have returned a Jira key!!")
    else
      #Jira uses a post call to the ticket key path to close the ticket. The "prepared batch of tickets" in this case is just a collection Jira ticket keys to close.
      tickets.push(queried_key)
    end
  end

  tickets
end

#prepare_create_tickets(vulnerability_list, nexpose_identifier_id) ⇒ Object

Prepares tickets from the CSV.



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 200

def prepare_create_tickets(vulnerability_list, nexpose_identifier_id)
  @metrics.start
  @log.log_message('Preparing ticket requests...')
  matching_fields = @mode_helper.get_matching_fields
  
  @ticket = Hash.new(-1)

  @log.log_message("Preparing tickets for #{@options[:ticket_mode]} mode.")
  tickets = []
  previous_row = nil
  description = nil
  CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
    if previous_row.nil?
      previous_row = row

      @ticket = {
          'fields' => {
              'project' => {
                  'key' => "#{@service_data[:project]}" },
              'summary' => @mode_helper.get_title(row),
              'description' => '',
              'issuetype' => {
                  'name' => 'Task' }
          }
      }
      description = @mode_helper.get_description(nexpose_identifier_id, row)
    elsif matching_fields.any? { |x| previous_row[x].nil? || previous_row[x] != row[x] }
      info = @mode_helper.get_field_info(matching_fields, previous_row)
      @log.log_message("Generated ticket with #{info}")

      @ticket['fields']['description'] = @mode_helper.print_description(description)
      tickets.push(@ticket.to_json)
      previous_row = nil
      description = nil
      redo
    else
      description = @mode_helper.update_description(description, row)
    end
  end

  unless @ticket.nil? || @ticket.empty?
    info = @mode_helper.get_field_info(matching_fields, previous_row)
    @log.log_message("Generated ticket with #{info}")
    @ticket['fields']['description'] = @mode_helper.print_description(description)
    tickets.push(@ticket.to_json)
  end

  tickets
end

#prepare_update_tickets(vulnerability_list, nexpose_identifier_id) ⇒ Object

Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose.

- +vulnerability_list+ -  CSV of vulnerabilities within Nexpose.
- +nexpose_identifier_id+ -  Site/TAG ID the vulnerability list was generate from.
  • Returns :

    • List of JSON-formated tickets for updating within Jira.



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 379

def prepare_update_tickets(vulnerability_list, nexpose_identifier_id)
  @metrics.start
  return unless @mode_helper.updates_supported?
  
  @log.log_message('Preparing tickets to update.')
  #Jira uses the ticket key to push updates. Since new IPs won't have a Jira key, generate new tickets for all of the IPs found.
  updated_tickets = prepare_create_tickets(vulnerability_list, nexpose_identifier_id)

  tickets_to_send = []

  #Find the keys that exist (IPs that have tickets already)
  updated_tickets.each do |ticket|
    description = JSON.parse(ticket)['fields']['description']
    nxid_index = description.rindex("NXID")
    nxid = nxid_index.nil? ? nil : description[nxid_index..-1]

    if (nxid).nil?
      #Could not get NXID from the last line in the description. Do not push the invalid description.
      @log.log_message("Failed to parse the NXID from a generated ticket update! Ignoring ticket <#{nxid}>")
      next
    end

    query_string = "jql=project=#{@service_data[:project]} AND description ~ \"#{nxid.strip}\" AND (status != #{@service_data[:close_step_name]})&fields=key"
    queried_key = get_jira_key(query_string, nxid)
    ticket_key_pair = []
    ticket_key_pair << queried_key
    ticket_key_pair << ticket
    tickets_to_send << ticket_key_pair
  end
  tickets_to_send
end

#send_jira_request(uri, request) ⇒ Object

Sends a request to the JIRA console.

  • Args :

    • uri - Address of the JIRA endpoint.

    • request - Request containing the query.

  • Returns :

    • HTTPResponse containing result from the JIRA console.



125
126
127
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 125

def send_jira_request(uri, request)
  send_request(uri, request)
end

#send_request(uri, request, ticket = false) ⇒ Object

Sends a HTTP request to the JIRA console.

  • Args :

    • uri - Address of the JIRA endpoint.

    • request - Request containing the query or ticket object.

  • Returns :

    • HTTPResponse containing result from the JIRA console.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 66

def send_request(uri, request, ticket=false)
  request.basic_auth @service_data[:username], @service_data[:password]
  resp = Net::HTTP.new(uri.host, uri.port)

  # Enable this line for debugging the https call.
  # resp.set_debug_output(@log)

  resp.use_ssl = uri.scheme == 'https'
  resp.verify_mode = OpenSSL::SSL::VERIFY_NONE

  return resp.request(request) unless ticket

  resp.start do |http|
    res = http.request(request)
    code = res.code.to_i

    next if code.between?(200,299)

    unless code.between?(400, 499)
      @log.log_error_message("Error submitting ticket data: #{res.message}, #{res.body}")
      return res
    end

    @log.log_error_message("Unable to access JIRA.")
    @log.log_error_message "Error code: #{code}"

    #Bad project etc
    case code
    when 400
      errors = res.body.scan(/errors":{(.+)}}/).first.first
      errors = errors.gsub('"', '').gsub(':', ': ').gsub(',', "\n")
      @log.log_error_message "Error messages:\n#{errors}"
    #Log in failed
    when 401
      @log.log_error_message "Message: #{res.message.strip}"
      @log.log_error_message "Reason: #{res['x-seraph-loginreason']}"
    #Locked out
    when 403
      @log.log_error_message "Message: #{res.message.strip}"
      @log.log_error_message "Reason: #{res['x-seraph-loginreason']}"
      @log.log_error_message "#{res['x-authentication-denied-reason']}"
    else
      #e.g. 404 - bad URL
      @log.log_error_message "Message: #{res.message.strip}"
    end

    return res
  end
end

#send_ticket(uri, request) ⇒ Object

Sends a ticket object to the JIRA console.

  • Args :

    • uri - Address of the JIRA endpoint.

    • request - Request containing the ticket object.

  • Returns :

    • HTTPResponse containing result from the JIRA console.



138
139
140
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 138

def send_ticket(uri, request)
  send_request(uri, request, true)
end

#update_tickets(tickets) ⇒ Object

Sends ticket updates (in JSON format) to Jira individually (each ticket in the list as a separate HTTP post).

  • Args :

    • tickets - List of JSON-formatted ticket updates.



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 337

def update_tickets(tickets)
  if (tickets.nil? || tickets.empty?) then
    @log.log_message('No tickets to update.')
  else
    tickets.each do |ticket_details|
      headers = {'Content-Type' => 'application/json',
                 'Accept' => 'application/json'}
    
      create_new_ticket = ticket_details.first.nil?

      url = "#{service_data[:jira_url]}"

      if create_new_ticket
        req = Net::HTTP::Post.new(url, headers)
        req.body = ticket_details.last
      else
        url += "#{ticket_details[0]}"
        req = Net::HTTP::Put.new(url, headers)
        req.body = {'update' => {'description' => [{'set' => "#{JSON.parse(ticket_details[1])['fields']['description']}"}]}}.to_json
      end

      response = send_ticket(URI.parse(url), req)
      code = response.nil? ? 1 : response.code.to_i
      break if code.between?(400, 499)
       
      if create_new_ticket
        @metrics.created
      else
        @metrics.updated
      end
    end
  end
end