Class: JiraHelper

Inherits:
Object
  • Object
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 collapse

Instance Method Summary collapse

Constructor Details

#initialize(jira_data, options) ⇒ JiraHelper

Returns a new instance of JiraHelper.



16
17
18
19
20
21
22
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 16

def initialize(jira_data, options)
  @jira_data = jira_data
  @options = options
  @log = NexposeTicketing::NxLogger.instance

  @common_helper = NexposeTicketing::CommonHelper.new(@options)
end

Instance Attribute Details

#jira_dataObject

Returns the value of attribute jira_data.



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

def jira_data
  @jira_data
end

#optionsObject

Returns the value of attribute options.



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

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



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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 222

def close_tickets(tickets)
  if tickets.nil? || tickets.empty?
    @log.log_message('No tickets to close.')
  else
    headers = { 'Content-Type' => 'application/json',
                'Accept' => 'application/json' }

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

      transition = get_jira_transition_details(ticket, @jira_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|
        if 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
          else
            if field[1]['schema']['type'] == 'array'
              required_fields << "\"#{field[0]}\" : [{\"id\" : \"#{field[1]['allowedValues'][0]['id']}\"}]"
            else
              required_fields << "\"#{field[0]}\" : {\"id\" : \"#{field[1]['allowedValues'][0]['id']}\"}"
            end
          end
        end
      end

      req.body = "{\"transition\" : {\"id\" : #{transition['id']}}, \"fields\" : { #{required_fields.join(",")}}}"
      send_ticket(uri, req)
    end
  end
end

#create_tickets(tickets) ⇒ Object



141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 141

def create_tickets(tickets)
  fail 'Ticket(s) cannot be empty.' if tickets.nil? || tickets.empty?
  tickets.each do |ticket|
    headers = { 'Content-Type' => 'application/json',
                'Accept' => 'application/json' }

    uri = URI.parse("#{@jira_data[:jira_url]}")
    req = Net::HTTP::Post.new(@jira_data[:jira_url], headers)
    req.body = ticket
    send_ticket(uri, req)
  end
end

#get_jira_key(jql_query) ⇒ 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.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 32

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

  uri = URI.parse(("#{@jira_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? || 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.

   @log.log_message("Jira returned no key or too many keys for query result! Response was <#{issues}>")
   return nil
 end
 return 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.



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 117

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(("#{@jira_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 <#{@jira_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.



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 272

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 = @common_helper.generate_nxid(nexpose_identifier_id, row)
    # Query Jira for the ticket by unique id (generated NXID)

    queried_key = get_jira_key("jql=project=#{@jira_data[:project]} AND description ~ \"NXID: #{@nxid}\" AND (status != #{@jira_data[:close_step_name]})&fields=key")
    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.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 155

def prepare_create_tickets(vulnerability_list, nexpose_identifier_id)
  @log.log_message('Preparing ticket requests...')
  case @options[:ticket_mode]
  # 'D' Default IP *-* Vulnerability

  when 'D' then matching_fields = ['ip_address', 'vulnerability_id']
  # 'I' IP address -* Vulnerability

  when 'I' then matching_fields = ['ip_address']
  # 'V' Vulnerability -* Assets

  when 'V' then matching_fields = ['vulnerability_id']
  else
      fail 'Unsupported ticketing mode selected.'
  end

  prepare_tickets(vulnerability_list, nexpose_identifier_id, matching_fields)
end

#prepare_tickets(vulnerability_list, nexpose_identifier_id, matching_fields) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 171

def prepare_tickets(vulnerability_list, nexpose_identifier_id, 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' => "#{@jira_data[:project]}" },
              'summary' => @common_helper.get_title(row),
              'description' => '',
              'issuetype' => {
                  'name' => 'Task' }
          }
      }
      description = @common_helper.get_description(nexpose_identifier_id, row)
    elsif matching_fields.any? { |x| previous_row[x].nil? || previous_row[x] != row[x] }
      info = @common_helper.get_field_info(matching_fields, previous_row)
      @log.log_message("Generated ticket with #{info}")

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

  unless @ticket.nil? || @ticket.empty?
    @ticket['fields']['description'] = @common_helper.print_description(description)
    tickets.push(@ticket.to_json)
  end

  @log.log_message("Generated <#{tickets.count.to_s}> tickets.")
  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.



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 329

def prepare_update_tickets(vulnerability_list, nexpose_identifier_id)
  fail 'Ticket updates are not supported in Default mode.' if @options[:ticket_mode] == 'D'
  @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
    queried_key = get_jira_key("jql=project=#{@jira_data[:project]} AND description ~ \"#{nxid.strip}\" AND (status != #{@jira_data[:close_step_name]})&fields=key")
    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.



90
91
92
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 90

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.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 61

def send_request(uri, request, ticket=false)
  request.basic_auth @jira_data[:username], @jira_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)
    next if res.code.to_i.between?(200,299)
    @log.log_error_message("Error submitting ticket data: #{res.message}, #{res.body}")
    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.



103
104
105
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 103

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.



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/nexpose_ticketing/helpers/jira_helper.rb', line 296

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'}

      (ticket_details.first.nil?) ? send_whole_ticket = true : send_whole_ticket = false

      url = "#{jira_data[:jira_url]}"
      url += "#{ticket_details.first}" unless send_whole_ticket
      uri = URI.parse(url)

      send_whole_ticket ? req = Net::HTTP::Post.new(uri.to_s, headers) : req = Net::HTTP::Put.new(uri.to_s, headers)

      send_whole_ticket ?

          req.body = ticket_details.last :
          req.body = {'update' => {'description' => [{'set' => "#{JSON.parse(ticket_details[1])['fields']['description']}"}]}}.to_json

      send_ticket(uri, req)
    end
  end
end