nexpose_ticketing 0.0.1 → 0.2.1

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.
@@ -0,0 +1,315 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'net/https'
4
+ require 'uri'
5
+ require 'csv'
6
+ require 'nexpose_ticketing/nx_logger'
7
+
8
+ # Serves as the ServiceNow interface for creating/updating issues from
9
+ # vulnelrabilities found in Nexpose.
10
+ class ServiceNowHelper
11
+ attr_accessor :servicenow_data, :options, :log
12
+ def initialize(servicenow_data, options)
13
+ @servicenow_data = servicenow_data
14
+ @options = options
15
+ @log = NexposeTicketing::NXLogger.new
16
+ end
17
+
18
+ # Sends a list of tickets (in JSON format) to ServiceNow individually (each ticket in the list
19
+ # as a separate HTTP post).
20
+ #
21
+ # * *Args* :
22
+ # - +tickets+ - List of JSON-formatted ticket creates (new tickets).
23
+ #
24
+ def create_tickets(tickets)
25
+ fail 'Ticket(s) cannot be empty' if tickets.nil? || tickets.empty?
26
+
27
+ tickets.each do |ticket|
28
+ send_ticket(ticket, @servicenow_data[:servicenow_url], @servicenow_data[:redirect_limit])
29
+ end
30
+ end
31
+
32
+ # Sends ticket updates (in JSON format) to ServiceNow individually (each ticket in the list as a
33
+ # separate HTTP post).
34
+ #
35
+ # * *Args* :
36
+ # - +tickets+ - List of JSON-formatted ticket updates.
37
+ #
38
+ def update_tickets(tickets)
39
+ if tickets.nil? || tickets.empty?
40
+ @log.log_message("No tickets to update.")
41
+ else
42
+ tickets.each do |ticket|
43
+ send_ticket(ticket, @servicenow_data[:servicenow_url], @servicenow_data[:redirect_limit])
44
+ end
45
+ end
46
+ end
47
+
48
+ # Sends ticket closure (in JSON format) to ServiceNow individually (each ticket in the list as a
49
+ # separate HTTP post).
50
+ #
51
+ # * *Args* :
52
+ # - +tickets+ - List of JSON-formatted ticket closures.
53
+ #
54
+ def close_tickets(tickets)
55
+ if tickets.nil? || tickets.empty?
56
+ @log.log_message("No tickets to close.")
57
+ else
58
+ tickets.each do |ticket|
59
+ send_ticket(ticket, @servicenow_data[:servicenow_url], @servicenow_data[:redirect_limit])
60
+ end
61
+ end
62
+ end
63
+
64
+ # Post an individual JSON-formatted ticket to ServiceNow. If the response from the post is a 301/
65
+ # 302 redirect, the method will attempt to resend the ticket to the response's location for up to
66
+ # [limit] times (which starts at the redirect_limit config value and is decremented with each
67
+ # redirect response.
68
+ #
69
+ # * *Args* :
70
+ # - +ticket+ - JSON-formatted ticket.
71
+ # - +url+ - URL to post the ticket to.
72
+ # - +limit+ - The amount of times to retry the send ticket request before failing.l
73
+ #
74
+ def send_ticket(ticket, url, limit)
75
+ raise ArgumentError, 'HTTP Redirect too deep' if limit == 0
76
+
77
+ uri = URI.parse(url)
78
+ headers = { 'Content-Type' => 'application/json',
79
+ 'Accept' => 'application/json' }
80
+ req = Net::HTTP::Post.new(url, headers)
81
+ req.basic_auth @servicenow_data[:username], @servicenow_data[:password]
82
+ req.body = ticket
83
+
84
+ resp = Net::HTTP.new(uri.host, uri.port)
85
+ # Setting verbose_mode to 'Y' will debug the https call(s).
86
+ resp.set_debug_output $stderr if @servicenow_data[:verbose_mode] == 'Y'
87
+ resp.use_ssl = true if uri.scheme == 'https'
88
+ # Currently, we do not verify SSL certificates (in case the local servicenow instance uses
89
+ # and unsigned or expired certificate)
90
+ resp.verify_mode = OpenSSL::SSL::VERIFY_NONE
91
+ res = resp.start { |http| http.request(req) }
92
+ case res
93
+ when Net::HTTPSuccess then res
94
+ when Net::HTTPRedirection then send_ticket(ticket, res['location'], limit - 1)
95
+ else
96
+ @log.log_message("Error in response: #{res['error']}")
97
+ res['error']
98
+ end
99
+ end
100
+
101
+ # Prepare tickets from the CSV of vulnerabilities exported from Nexpose. This method determines
102
+ # how to prepare the tickets (either by default or by IP address) based on config options.
103
+ #
104
+ # * *Args* :
105
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
106
+ #
107
+ # * *Returns* :
108
+ # - List of JSON-formated tickets for creating within ServiceNow.
109
+ #
110
+ def prepare_create_tickets(vulnerability_list)
111
+ @ticket = Hash.new(-1)
112
+ case @options[:ticket_mode]
113
+ # 'D' Default mode: IP *-* Vulnerability
114
+ when 'D'
115
+ prepare_create_tickets_default(vulnerability_list)
116
+ # 'I' IP address mode: IP address -* Vulnerability
117
+ when 'I'
118
+ prepare_create_tickets_by_ip(vulnerability_list)
119
+ else
120
+ fail 'No ticketing mode selected.'
121
+ end
122
+ end
123
+
124
+ # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
125
+ # ServiceNow. The preparation by default means that each vulnerability within Nexpose is a
126
+ # separate incident within ServiceNow. This makes for smaller, more actionalble incidents but
127
+ # could lead to a very large total number of incidents.
128
+ #
129
+ # * *Args* :
130
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
131
+ #
132
+ # * *Returns* :
133
+ # - List of JSON-formated tickets for creating within ServiceNow.
134
+ #
135
+ def prepare_create_tickets_default(vulnerability_list)
136
+ @log.log_message("Preparing tickets by default method.")
137
+ tickets = []
138
+ CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
139
+ # ServiceNow doesn't allow new line characters in the incident short description.
140
+ summary = row['summary'].gsub(/\n/, ' ')
141
+
142
+ @log.log_message("Creating ticket with IP address: #{row['ip_address']} and summary: #{summary}")
143
+ # NXID in the work_notes is a unique identifier used to query incidents to update/resolve
144
+ # incidents as they are resolved in Nexpose.
145
+ ticket = {
146
+ 'sysparm_action' => 'insert',
147
+ 'caller_id' => "#{@servicenow_data[:username]}",
148
+ 'category' => 'Software',
149
+ 'impact' => '1',
150
+ 'urgency' => '1',
151
+ 'short_description' => "#{row['ip_address']} => #{summary}",
152
+ 'work_notes' => "Summary: #{summary}
153
+ Fix: #{row['fix']}
154
+ ----------------------------------------------------------------------------
155
+ URL: #{row['url']}
156
+ NXID: #{row['asset_id']}#{row['vulnerability_id']}#{row['solution_id']}"
157
+ }.to_json
158
+ tickets.push(ticket)
159
+ end
160
+ tickets
161
+ end
162
+
163
+ # Prepares a list of vulnerabilities into a list of JSON-formatted tickets (incidents) for
164
+ # ServiceNow. The preparation by IP means that all vulnerabilities within Nexpose for one IP
165
+ # address are consolidated into a single ServiceNow incident. This reduces the number of incidents
166
+ # within ServiceNow but greatly increases the size of the work notes.
167
+ #
168
+ # * *Args* :
169
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
170
+ #
171
+ # * *Returns* :
172
+ # - List of JSON-formated tickets for creating within ServiceNow.
173
+ #
174
+ def prepare_create_tickets_by_ip(vulnerability_list)
175
+ @log.log_message("Preparing tickets by IP address.")
176
+ tickets = []
177
+ current_ip = -1
178
+ CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
179
+ if current_ip == -1
180
+ current_ip = row['ip_address']
181
+ @log.log_message("Creating ticket with IP address: #{row['ip_address']}")
182
+ @ticket = {
183
+ 'sysparm_action' => 'insert',
184
+ 'caller_id' => "#{@servicenow_data[:username]}",
185
+ 'category' => 'Software',
186
+ 'impact' => '1',
187
+ 'urgency' => '1',
188
+ 'short_description' => "#{row['ip_address']} => Vulnerabilities",
189
+ 'work_notes' => "\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
190
+ ++ New Vulnerabilities +++++++++++++++++++++++++++++++++++++
191
+ +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
192
+ }
193
+ end
194
+ if current_ip == row['ip_address']
195
+ @ticket['work_notes'] +=
196
+ "==========================================
197
+ Summary: #{row['summary']}
198
+ ----------------------------------------------------------------------------
199
+ Fix: #{row['fix']}"
200
+ unless row['url'].nil?
201
+ @ticket['work_notes'] +=
202
+ "----------------------------------------------------------------------------
203
+ URL: #{row['url']}"
204
+ end
205
+ end
206
+ unless current_ip == row['ip_address']
207
+ # NXID in the work_notes is the unique identifier used to query incidents to update them.
208
+ @ticket['work_notes'] += "\nNXID: #{current_ip}"
209
+ @ticket = @ticket.to_json
210
+ tickets.push(@ticket)
211
+ current_ip = -1
212
+ redo
213
+ end
214
+ end
215
+ # NXID in the work_notes is the unique identifier used to query incidents to update them.
216
+ @ticket['work_notes'] += "\nNXID: #{current_ip}"
217
+ tickets.push(@ticket.to_json) unless @ticket.nil?
218
+ tickets
219
+ end
220
+
221
+ # Prepare ticket updates from the CSV of vulnerabilities exported from Nexpose. This method
222
+ # currently only supports updating IP-address mode tickets in ServiceNow. The list of vulnerabilities
223
+ # are ordered by IP address and then by ticket_status, allowing the method to loop through and
224
+ # display new, old, and same vulnerabilities in that order.
225
+ #
226
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
227
+ #
228
+ # * *Returns* :
229
+ # - List of JSON-formated tickets for updating within ServiceNow.
230
+ #
231
+ def prepare_update_tickets(vulnerability_list)
232
+ fail 'Ticket updates are only supported in IP-address mode.' if @options[:ticket_mode] == 'D'
233
+ @ticket = Hash.new(-1)
234
+
235
+ @log.log_message("Preparing ticket updates by IP address.")
236
+ tickets = []
237
+ current_ip = -1
238
+ ticket_status = 'New'
239
+ CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
240
+ if current_ip == -1
241
+ current_ip = row['ip_address']
242
+ ticket_status = row['comparison']
243
+ @log.log_message("Creating ticket update with IP address: #{row['ip_address']}")
244
+ @log.log_message("Ticket status #{ticket_status}")
245
+ @ticket = {
246
+ 'sysparm_action' => 'update',
247
+ 'sysparm_query' => "work_notesCONTAINSNXID: #{row['ip_address']}",
248
+ 'work_notes' =>
249
+ "\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
250
+ ++ #{row['comparison']} Vulnerabilities +++++++++++++++++++++++++++++++++++++
251
+ +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
252
+ }
253
+ end
254
+ if current_ip == row['ip_address']
255
+ # If the ticket_status is different, add a a new 'header' to signify a new block of tickets.
256
+ unless ticket_status == row['comparison']
257
+ @ticket['work_notes'] +=
258
+ "\n+++++++++++++++++++++++++++++++++++++++++++++++++++++++
259
+ ++ #{row['comparison']} Vulnerabilities +++++++++++++++++++++++++++++++++++++
260
+ +++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n"
261
+ ticket_status = row['comparison']
262
+ end
263
+
264
+ @ticket['work_notes'] +=
265
+ "\n\n==========================================
266
+ Summary: #{row['summary']}
267
+ ----------------------------------------------------------------------------
268
+ Fix: #{row['fix']}"
269
+ # Only add the URL block if data exists in the row.
270
+ unless row['url'].nil?
271
+ @ticket['work_notes'] +=
272
+ "----------------------------------------------------------------------------
273
+ URL: #{row['url']}"
274
+ end
275
+ end
276
+ unless current_ip == row['ip_address']
277
+ # NXID in the work_notes is the unique identifier used to query incidents to update them.
278
+ @ticket['work_notes'] += "\nNXID: #{current_ip}"
279
+ @ticket = @ticket.to_json
280
+ tickets.push(@ticket)
281
+ current_ip = -1
282
+ redo
283
+ end
284
+ end
285
+ # NXID in the work_notes is the unique identifier used to query incidents to update them.
286
+ @ticket['work_notes'] += "\nNXID: #{current_ip}"
287
+ tickets.push(@ticket.to_json) unless @ticket.nil?
288
+ tickets
289
+ end
290
+
291
+ # Prepare ticket closures from the CSV of vulnerabilities exported from Nexpose. This method
292
+ # currently only supports updating default mode tickets in ServiceNow.
293
+ #
294
+ # * *Args* :
295
+ # - +vulnerability_list+ - CSV of vulnerabilities within Nexpose.
296
+ #
297
+ # * *Returns* :
298
+ # - List of JSON-formated tickets for closing within ServiceNow.
299
+ #
300
+ def prepare_close_tickets(vulnerability_list)
301
+ fail 'Ticket closures are only supported in default mode.' if @options[:ticket_mode] == 'I'
302
+ @log.log_message("Preparing ticket closures by default method.")
303
+ tickets = []
304
+ CSV.parse(vulnerability_list.chomp, headers: :first_row) do |row|
305
+ # 'state' 7 is the "Closed" state within ServiceNow.
306
+ ticket = {
307
+ 'sysparm_action' => 'update',
308
+ 'sysparm_query' => "work_notesCONTAINSNXID: #{row['asset_id']}#{row['vulnerability_id']}#{row['solution_id']}",
309
+ 'state' => '7'
310
+ }.to_json
311
+ tickets.push(ticket)
312
+ end
313
+ tickets
314
+ end
315
+ end
@@ -0,0 +1,35 @@
1
+ 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}"
13
+ 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.')
27
+ end
28
+ end
29
+
30
+ # Logs a message if logging is enabled.
31
+ def log_message(message)
32
+ @log.info(message) if @options[:logging_enabled]
33
+ end
34
+ end
35
+ end
@@ -6,18 +6,21 @@ module NexposeTicketing
6
6
  module Queries
7
7
  # Gets all the latests scans.
8
8
  # Returns |site.id| |last_scan_id| |finished|
9
- def Queries.last_scans
9
+ def self.last_scans
10
10
  'SELECT ds.site_id, ds.last_scan_id, dsc.finished
11
11
  FROM dim_site ds
12
12
  JOIN dim_scan dsc ON ds.last_scan_id = dsc.scan_id'
13
13
  end
14
14
 
15
15
  # Gets all delta vulns for all sites.
16
- # Returns |asset_id| |ip_address| |current_scan| |vulnerability_id|
17
- # |solution_id| |nexpose_id| |url| |summary| |fix|
18
- def Queries.all_delta_vulns
19
- "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, davs.solution_id, ds.nexpose_id, ds.url,
20
- proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix
16
+ #
17
+ # * *Returns* :
18
+ # -Returns |asset_id| |ip_address| |current_scan| |vulnerability_id||solution_id| |nexpose_id|
19
+ # |url| |summary| |fix|
20
+ #
21
+ def self.all_new_vulns
22
+ "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, davs.solution_id, ds.nexpose_id,
23
+ ds.url,proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix
21
24
  FROM (SELECT fasv.asset_id, fasv.vulnerability_id, s.current_scan
22
25
  FROM fact_asset_scan_vulnerability_finding fasv
23
26
  JOIN
@@ -33,20 +36,26 @@ module NexposeTicketing
33
36
  JOIN dim_asset da ON subs.asset_id = da.asset_id
34
37
  ORDER BY da.ip_address"
35
38
  end
36
-
37
- # Gets all delta vulns happening after reported scan id
38
- # Returns |asset_id| |ip_address| |current_scan| |vulnerability_id|
39
- # |solution_id| |nexpose_id| |url| |summary| |fix|
40
- def Queries.delta_vulns_since_scan(reported_scan)
41
- "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, davs.solution_id, ds.nexpose_id, ds.url,
42
- proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix
39
+
40
+ # Gets all new vulnerabilities happening after a reported scan id.
41
+ #
42
+ # * *Args* :
43
+ # - +reported_scan+ - Last reported scan id.
44
+ #
45
+ # * *Returns* :
46
+ # - Returns |asset_id| |ip_address| |current_scan| |vulnerability_id| |solution_id| |nexpose_id|
47
+ # |url| |summary| |fix|
48
+ #
49
+ def self.new_vulns_since_scan(reported_scan)
50
+ "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, davs.solution_id, ds.nexpose_id,
51
+ ds.url, proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix
43
52
  FROM (SELECT fasv.asset_id, fasv.vulnerability_id, s.current_scan
44
53
  FROM fact_asset_scan_vulnerability_finding fasv
45
54
  JOIN
46
55
  (
47
56
  SELECT asset_id, previousScan(asset_id) AS baseline_scan, lastScan(asset_id) AS current_scan
48
57
  FROM dim_asset) s
49
- ON s.asset_id = fasv.asset_id AND (fasv.scan_id > #{reported_scan} OR fasv.scan_id = s.current_scan)
58
+ ON s.asset_id = fasv.asset_id AND (fasv.scan_id >= #{reported_scan} OR fasv.scan_id = s.current_scan)
50
59
  GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan
51
60
  HAVING baselineComparison(fasv.scan_id, current_scan) = 'New'
52
61
  ) subs
@@ -56,5 +65,87 @@ module NexposeTicketing
56
65
  AND subs.current_scan > #{reported_scan}
57
66
  ORDER BY da.ip_address"
58
67
  end
68
+
69
+ # Gets all old vulnerabilities happening after a reported scan id.
70
+ #
71
+ # * *Args* :
72
+ # - +reported_scan+ - Last reported scan id.
73
+ #
74
+ # * *Returns* :
75
+ # - Returns |asset_id| |ip_address| |current_scan| |vulnerability_id| |solution_id| |nexpose_id|
76
+ # |url| |summary| |fix|
77
+ #
78
+ def self.old_vulns_since_scan(reported_scan)
79
+ "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, dvs.solution_id, ds.nexpose_id, ds.url,
80
+ proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix, subs.comparison
81
+ FROM (
82
+ SELECT fasv.asset_id, fasv.vulnerability_id, s.current_scan, baselineComparison(fasv.scan_id, s.current_scan) as comparison
83
+ FROM fact_asset_scan_vulnerability_finding fasv
84
+ JOIN (
85
+ SELECT asset_id, previousScan(asset_id) AS baseline_scan, lastScan(asset_id) AS current_scan
86
+ FROM dim_asset
87
+ ) s ON s.asset_id = fasv.asset_id AND (fasv.scan_id >= #{reported_scan} OR fasv.scan_id = s.current_scan)
88
+ GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan
89
+ HAVING baselineComparison(fasv.scan_id, current_scan) = 'Old'
90
+ ) subs
91
+ JOIN dim_vulnerability_solution dvs USING (vulnerability_id)
92
+ JOIN dim_solution ds USING (solution_id)
93
+ JOIN dim_asset da ON subs.asset_id = da.asset_id
94
+ AND subs.current_scan > #{reported_scan}
95
+ ORDER BY da.ip_address"
96
+ end
97
+
98
+ # Gets all vulnerabilities happening after a reported scan id. This result set also includes the
99
+ # baseline comparision ("Old", "New", or "Same") allowing for IP-based ticket updating.
100
+ #
101
+ # * *Args* :
102
+ # - +reported_scan+ - Last reported scan id.
103
+ #
104
+ # * *Returns* :
105
+ # - Returns |asset_id| |ip_address| |current_scan| |vulnerability_id| |solution_id| |nexpose_id|
106
+ # |url| |summary| |fix| |comparison|
107
+ #
108
+ def self.all_vulns_since_scan(reported_scan)
109
+ "SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, dvs.solution_id, ds.nexpose_id, ds.url,
110
+ proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix, subs.comparison
111
+ FROM (
112
+ SELECT fasv.asset_id, fasv.vulnerability_id, s.current_scan, baselineComparison(fasv.scan_id, s.current_scan) as comparison
113
+ FROM fact_asset_scan_vulnerability_finding fasv
114
+ JOIN (
115
+ SELECT asset_id, previousScan(asset_id) AS baseline_scan, lastScan(asset_id) AS current_scan
116
+ FROM dim_asset
117
+ ) s ON s.asset_id = fasv.asset_id AND (fasv.scan_id >= #{reported_scan} OR fasv.scan_id = s.current_scan)
118
+ GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan
119
+ HAVING baselineComparison(fasv.scan_id, current_scan) = 'Old'
120
+ ) subs
121
+ JOIN dim_vulnerability_solution dvs USING (vulnerability_id)
122
+ JOIN dim_solution ds USING (solution_id)
123
+ JOIN dim_asset da ON subs.asset_id = da.asset_id
124
+ AND subs.current_scan > #{reported_scan}
125
+
126
+ UNION
127
+
128
+ SELECT subs.asset_id, da.ip_address, subs.current_scan, subs.vulnerability_id, davs.solution_id, ds.nexpose_id, ds.url,
129
+ proofAsText(ds.summary) as summary, proofAsText(ds.fix) as fix, subs.comparison
130
+ FROM
131
+ (
132
+ SELECT fasv.asset_id, fasv.vulnerability_id, s.current_scan, baselineComparison(fasv.scan_id, s.current_scan) as comparison
133
+ FROM fact_asset_scan_vulnerability_finding fasv
134
+ JOIN
135
+ (
136
+ SELECT asset_id,lastScan(asset_id) AS current_scan
137
+ FROM dim_asset
138
+ ) s ON s.asset_id = fasv.asset_id AND (fasv.scan_id >= #{reported_scan} OR fasv.scan_id = s.current_scan)
139
+ GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan
140
+ HAVING baselineComparison(fasv.scan_id, current_scan) IN ('Same','New')
141
+ ) subs
142
+ JOIN dim_asset_vulnerability_solution davs USING (vulnerability_id)
143
+ JOIN dim_solution ds USING (solution_id)
144
+ JOIN dim_asset da ON subs.asset_id = da.asset_id
145
+ AND subs.current_scan > #{reported_scan}
146
+
147
+ ORDER BY ip_address, comparison"
148
+ end
149
+
59
150
  end
60
151
  end