nexpose_servicenow 0.4.24 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +11 -3
- data/lib/nexpose_servicenow.rb +67 -58
- data/lib/nexpose_servicenow/arg_parser.rb +149 -51
- data/lib/nexpose_servicenow/chunker.rb +19 -14
- data/lib/nexpose_servicenow/historical_data.rb +179 -65
- data/lib/nexpose_servicenow/nexpose_helper.rb +75 -53
- data/lib/nexpose_servicenow/queries.rb +145 -34
- data/lib/nexpose_servicenow/version.rb +3 -3
- metadata +2 -2
@@ -7,12 +7,12 @@ module NexposeServiceNow
|
|
7
7
|
class NexposeHelper
|
8
8
|
def initialize(url, port, username, password)
|
9
9
|
@log = NexposeServiceNow::NxLogger.instance
|
10
|
-
|
11
|
-
|
10
|
+
@url = url
|
11
|
+
@port = port
|
12
12
|
@username = username
|
13
13
|
@password = password
|
14
14
|
|
15
|
-
|
15
|
+
@nsc = connect(username, password)
|
16
16
|
|
17
17
|
@timeout = 7200
|
18
18
|
end
|
@@ -22,7 +22,7 @@ module NexposeServiceNow
|
|
22
22
|
return [ id: -1, report_name: get_report_name(query_name) ]
|
23
23
|
end
|
24
24
|
|
25
|
-
ids.map { |id| { id: id, report_name: get_report_name(query_name, id) }}
|
25
|
+
ids.map { |id| { id: id, report_name: get_report_name(query_name, id) } }
|
26
26
|
end
|
27
27
|
|
28
28
|
def self.get_report_name(query_name, id=nil)
|
@@ -37,36 +37,60 @@ module NexposeServiceNow
|
|
37
37
|
end
|
38
38
|
|
39
39
|
def create_report(query_name, ids, id_type, output_dir, query_options={})
|
40
|
-
|
40
|
+
output_dir = File.expand_path(output_dir.to_s)
|
41
41
|
|
42
42
|
#A single report doesn't use site filters
|
43
43
|
ids = [-1] unless Queries.multiple_reports?(query_name)
|
44
44
|
|
45
|
+
#If running latest_scans, send up information
|
46
|
+
if query_name == 'latest_scans'
|
47
|
+
@log.on_connect(@url, @port, @nsc.session_id, '{}')
|
48
|
+
end
|
49
|
+
|
45
50
|
reports = []
|
46
51
|
ids.each do |id|
|
47
52
|
report_name = self.class.get_report_name(query_name, id)
|
48
53
|
clean_up_reports(report_name)
|
49
54
|
|
50
|
-
|
51
|
-
|
55
|
+
# Should use the value from historical_data.rb
|
56
|
+
default_delta = if id_type.to_s == 'asset_group'
|
57
|
+
'1985-01-01 00:00:00'
|
58
|
+
elsif id_type.to_s == 'site'
|
59
|
+
0
|
60
|
+
end
|
61
|
+
|
62
|
+
delta_options = create_query_options(query_options, id, default_delta)
|
63
|
+
|
64
|
+
# TODO: Refactor this into 'create_query_options'
|
65
|
+
delta_options[:site_id] = id
|
66
|
+
delta_options[:id_type] = id_type.to_s
|
67
|
+
delta_options[:filters] = query_options[:filters] || {}
|
68
|
+
|
69
|
+
min_cvss = if delta_options[:filters][:cvss].nil?
|
70
|
+
0
|
71
|
+
else
|
72
|
+
delta_options[:filters][:cvss].first
|
73
|
+
end
|
52
74
|
|
53
75
|
query = Queries.send(query_name, delta_options)
|
54
|
-
|
76
|
+
|
77
|
+
report_id = generate_config(query, report_name, [id], id_type, min_cvss)
|
55
78
|
|
56
79
|
run_report(report_id)
|
57
80
|
reports << save_report(report_name, report_id, output_dir)
|
58
81
|
end
|
59
82
|
|
60
|
-
|
83
|
+
reports
|
61
84
|
end
|
62
85
|
|
63
|
-
def create_query_options(query_options, nexpose_id=nil)
|
86
|
+
def create_query_options(query_options, nexpose_id=nil, default=0)
|
64
87
|
options = {}
|
65
88
|
options[:vuln_query_date] = query_options[:vuln_query_date]
|
66
89
|
|
67
|
-
return options if nexpose_id
|
68
|
-
return 0 if query_options[:
|
69
|
-
|
90
|
+
return options if nexpose_id.nil? || nexpose_id == -1
|
91
|
+
return 0 if query_options[:delta_values].empty?
|
92
|
+
|
93
|
+
options[:delta] = "#{query_options[:delta_values][nexpose_id] || default}"
|
70
94
|
|
71
95
|
@log.log_message("Query options: #{options}")
|
72
96
|
|
@@ -74,23 +98,23 @@ module NexposeServiceNow
|
|
74
98
|
end
|
75
99
|
|
76
100
|
def clean_up_reports(report_name)
|
77
|
-
#log 'Deleting existing report...'
|
78
101
|
reports = @nsc.list_reports
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
end
|
83
|
-
end
|
102
|
+
reports.select! { |r| r.name.start_with? report_name }
|
103
|
+
reports.each { |r| @nsc.delete_report_config(r.config_id) }
|
104
|
+
end
|
84
105
|
|
85
|
-
def generate_config(query, report_name, ids, id_type)
|
106
|
+
def generate_config(query, report_name, ids, id_type, min_severity=0)
|
86
107
|
@nsc = connect(@username, @password)
|
87
|
-
@log.log_message "Generating
|
88
|
-
|
108
|
+
@log.log_message "Generating report config with name #{report_name}..."
|
109
|
+
report_config = Nexpose::ReportConfig.new(report_name, nil, 'sql')
|
89
110
|
report_config.add_filter('version', '2.0.1')
|
90
111
|
report_config.add_filter('query', query)
|
91
112
|
|
113
|
+
id_type = id_type.to_s.split('_').last
|
114
|
+
ids.each { |id| report_config.add_filter(id_type, id) unless id == -1 }
|
115
|
+
|
116
|
+
|
92
117
|
@log.log_message "Saving report config #{report_name}..."
|
93
|
-
ids.each { |id| report_config.add_filter(id_type, id) if id != -1 }
|
94
118
|
report_id = report_config.save(@nsc, false)
|
95
119
|
@log.log_message "Report #{report_name} saved"
|
96
120
|
|
@@ -98,13 +122,13 @@ module NexposeServiceNow
|
|
98
122
|
end
|
99
123
|
|
100
124
|
def run_report(report_id)
|
101
|
-
|
125
|
+
@log.log_message "Running report #{report_id}..."
|
102
126
|
@nsc.generate_report(report_id, false)
|
103
127
|
wait_for_report(report_id)
|
104
128
|
end
|
105
129
|
|
106
130
|
def wait_for_report(id)
|
107
|
-
wait_until(:fail_on_exceptions =>
|
131
|
+
wait_until(:fail_on_exceptions => true, :on_timeout => "Report generation timed out. Status: #{r = @nsc.last_report(id); r ? r.status : 'unknown'}") {
|
108
132
|
if %w(Failed Aborted Unknown).include?(@nsc.last_report(id).status)
|
109
133
|
raise "Report failed to generate! Status <#{@nsc.last_report(id).status}>"
|
110
134
|
end
|
@@ -132,13 +156,13 @@ module NexposeServiceNow
|
|
132
156
|
end
|
133
157
|
|
134
158
|
def save_report(report_name, report_id, output_dir)
|
135
|
-
|
136
|
-
|
137
|
-
|
159
|
+
@log.log_message 'Saving report...'
|
160
|
+
local_file_name = self.class.get_filepath(report_name, output_dir)
|
161
|
+
File.delete(local_file_name) if File.exists? local_file_name
|
138
162
|
|
139
|
-
|
140
|
-
|
141
|
-
|
163
|
+
#log 'Downloading report...'
|
164
|
+
report_details = @nsc.last_report(report_id)
|
165
|
+
File.open(local_file_name, 'wb') do |f|
|
142
166
|
f.write(@nsc.download(report_details.uri))
|
143
167
|
end
|
144
168
|
|
@@ -146,35 +170,33 @@ module NexposeServiceNow
|
|
146
170
|
# Refresh the connection
|
147
171
|
@nsc = connect(@username, @password)
|
148
172
|
|
149
|
-
|
150
|
-
|
173
|
+
#Got the report, cleanup server-side
|
174
|
+
@nsc.delete_report_config(report_id)
|
151
175
|
rescue
|
152
|
-
@log.log_error_message
|
176
|
+
@log.log_error_message 'Error deleting report'
|
153
177
|
end
|
154
178
|
|
155
179
|
local_file_name
|
156
180
|
end
|
157
181
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
#@log.on_connect(@url, @port || 3780, console.session_id, "{}")
|
172
|
-
console
|
173
|
-
end
|
174
|
-
|
175
|
-
def all_sites
|
176
|
-
@nsc.sites.map { |s| s.id }
|
182
|
+
def connect(username, password)
|
183
|
+
begin
|
184
|
+
connection = Nexpose::Connection.new(@url, username, password, @port)
|
185
|
+
connection.login
|
186
|
+
@log.log_message 'Logged in.'
|
187
|
+
rescue Exception => e
|
188
|
+
msg = "ERROR: Could not log in. Check log and settings.\n#{e}"
|
189
|
+
@log.log_error_message msg
|
190
|
+
$stderr.puts msg
|
191
|
+
exit -1
|
192
|
+
end
|
193
|
+
|
194
|
+
connection
|
177
195
|
end
|
178
196
|
|
197
|
+
# Pulls the collection IDs from Nexpose (e.g. asset groups, sites)
|
198
|
+
def collection_ids(collection_type)
|
199
|
+
@nsc.send("#{collection_type}s").map { |s| s.id }.sort
|
200
|
+
end
|
179
201
|
end
|
180
202
|
end
|
@@ -98,8 +98,8 @@ module NexposeServiceNow
|
|
98
98
|
FROM dim_vulnerability_category dvc) dvc USING (vulnerability_id)
|
99
99
|
WHERE date_modified >= '#{options[:vuln_query_date]}'"
|
100
100
|
end
|
101
|
-
|
102
|
-
#Filter by site.
|
101
|
+
|
102
|
+
# Filter by site.
|
103
103
|
def self.assets(options={})
|
104
104
|
"SELECT coalesce(host_name, CAST(dim_asset.asset_id as text)) as Name,
|
105
105
|
dim_asset.ip_address,
|
@@ -116,7 +116,6 @@ module NexposeServiceNow
|
|
116
116
|
fact_asset.pci_status
|
117
117
|
|
118
118
|
FROM dim_asset
|
119
|
-
JOIN (select * from dim_site_asset WHERE site_id=#{options['site_id']}) dsa USING (asset_id)
|
120
119
|
JOIN fact_asset USING (asset_id)
|
121
120
|
LEFT OUTER JOIN dim_operating_system on dim_asset.operating_system_id = dim_operating_system.operating_system_id
|
122
121
|
LEFT OUTER JOIN dim_host_type USING (host_type_id)"
|
@@ -131,58 +130,133 @@ module NexposeServiceNow
|
|
131
130
|
end
|
132
131
|
|
133
132
|
def self.service_instance(options={})
|
134
|
-
|
133
|
+
'SELECT ds.Service_Name, asset_id as Nexpose_ID, port, dp.protocol, dsf.name
|
135
134
|
FROM fact_asset_scan_service
|
136
135
|
LEFT OUTER JOIN (SELECT service_id, name as service_name FROM dim_service) ds USING (service_id)
|
137
136
|
LEFT OUTER JOIN (SELECT service_fingerprint_id, name FROM dim_service_fingerprint) dsf USING (service_fingerprint_id)
|
138
137
|
LEFT OUTER JOIN (SELECT protocol_id, name as protocol FROM dim_protocol) dp USING (protocol_id)
|
139
|
-
WHERE scan_id = lastScan(asset_id)
|
138
|
+
WHERE scan_id = lastScan(asset_id)'
|
140
139
|
end
|
141
140
|
|
142
141
|
|
143
|
-
|
142
|
+
# Need to wipe table each time
|
144
143
|
def self.group_accounts(options={})
|
145
|
-
|
144
|
+
'SELECT asset_id as Nexpose_ID, daga.name as Group_Account_Name
|
146
145
|
FROM dim_asset
|
147
|
-
JOIN dim_asset_group_account daga USING (asset_id)
|
146
|
+
JOIN dim_asset_group_account daga USING (asset_id)'
|
148
147
|
end
|
149
148
|
|
150
|
-
|
149
|
+
# Need to wipe table each time
|
151
150
|
def self.user_accounts(options={})
|
152
|
-
|
151
|
+
'SELECT da.asset_id as Nexpose_ID, daua.name as User_Account_Name,
|
153
152
|
daua.full_name as User_Account_Full_Name
|
154
153
|
|
155
154
|
FROM dim_asset da
|
156
|
-
JOIN dim_asset_user_account daua USING (asset_id)
|
155
|
+
JOIN dim_asset_user_account daua USING (asset_id)'
|
157
156
|
end
|
158
157
|
|
159
|
-
#Need to wipe table each time
|
158
|
+
# Need to wipe table each time
|
160
159
|
def self.asset_groups(options={})
|
161
|
-
|
160
|
+
'SELECT asset_id as Nexpose_ID, dag.name as Asset_Group_Name,
|
162
161
|
dag.dynamic_membership, dag.description
|
163
162
|
FROM dim_asset_group_asset daga
|
164
|
-
JOIN dim_asset_group dag on daga.asset_group_id = dag.asset_group_id
|
163
|
+
JOIN dim_asset_group dag on daga.asset_group_id = dag.asset_group_id'
|
165
164
|
end
|
166
165
|
|
167
|
-
#Need to wipe table each time
|
166
|
+
# Need to wipe table each time
|
168
167
|
def self.sites(options={})
|
169
|
-
|
168
|
+
'SELECT asset_id as Nexpose_ID, ds.name as site_name
|
170
169
|
FROM dim_asset
|
171
170
|
JOIN dim_site_asset dsa USING (asset_id)
|
172
171
|
JOIN dim_site ds on dsa.site_id = ds.site_id
|
173
|
-
ORDER BY ip_address
|
172
|
+
ORDER BY ip_address'
|
174
173
|
end
|
175
174
|
|
176
|
-
#Need to wipe table each time
|
175
|
+
# Need to wipe table each time
|
177
176
|
def self.tags(options={})
|
178
|
-
|
177
|
+
'SELECT asset_id as Nexpose_ID, dt.tag_name
|
179
178
|
FROM dim_tag_asset dta
|
180
|
-
JOIN dim_tag dt on dta.tag_id = dt.tag_id
|
179
|
+
JOIN dim_tag dt on dta.tag_id = dt.tag_id'
|
180
|
+
end
|
181
|
+
|
182
|
+
def self.generate_cve_filter(cves)
|
183
|
+
return '' if cves == nil || cves.empty?
|
184
|
+
|
185
|
+
cves = cves.map { |c| "reference='#{c}'" }.join(' OR ')
|
186
|
+
|
187
|
+
"JOIN (SELECT vulnerability_id, reference
|
188
|
+
FROM dim_vulnerability_reference
|
189
|
+
WHERE #{cves}) dvr USING (vulnerability_id)"
|
190
|
+
end
|
191
|
+
|
192
|
+
#TODO make sure that for max date first_discovered < date2
|
193
|
+
def self.generate_date_filter(dates, table_join=true)
|
194
|
+
return '' if dates == nil
|
195
|
+
|
196
|
+
# No filters applied, so no need to filter
|
197
|
+
return '' if dates.all? { |d| d == nil || d == '' }
|
198
|
+
|
199
|
+
min_date = dates.first
|
200
|
+
max_date = dates.last
|
201
|
+
|
202
|
+
# Version of vulnerable new items
|
203
|
+
unless table_join
|
204
|
+
filters = []
|
205
|
+
filters << "first_discovered >= '#{min_date}'" unless min_date.nil?
|
206
|
+
filters << "first_discovered <= '#{max_date}'" unless max_date.nil?
|
207
|
+
filters = filters.join(' AND ')
|
208
|
+
filters = "WHERE #{filters}"
|
209
|
+
|
210
|
+
return filters
|
211
|
+
end
|
212
|
+
|
213
|
+
date_filters = []
|
214
|
+
|
215
|
+
unless min_date.nil?
|
216
|
+
date_filters << "scan_finished > '#{min_date}'"
|
217
|
+
end
|
218
|
+
unless max_date.nil?
|
219
|
+
date_filters << "scan_started < '#{max_date}'"
|
220
|
+
end
|
221
|
+
|
222
|
+
condition = date_filters.join(' AND ')
|
223
|
+
"JOIN (SELECT scan_id, asset_id
|
224
|
+
FROM fact_asset_scan
|
225
|
+
WHERE #{condition}) fas
|
226
|
+
ON fas.asset_id = da.asset_ID AND
|
227
|
+
fas.scan_id = first_found"
|
228
|
+
end
|
229
|
+
|
230
|
+
def self.generate_cvss_filter(cvss_range)
|
231
|
+
return '' if cvss_range.nil? || cvss_range.last.nil?
|
232
|
+
|
233
|
+
cvss_min = cvss_range.first
|
234
|
+
cvss_max = cvss_range.last
|
235
|
+
|
236
|
+
# No need to join if not applying a filter
|
237
|
+
return '' if cvss_min.to_s == '0' && cvss_max.to_s == '10'
|
238
|
+
|
239
|
+
"JOIN (SELECT vulnerability_id, cvss_score
|
240
|
+
FROM dim_vulnerability
|
241
|
+
WHERE cvss_score >= #{cvss_min} AND cvss_score <= #{cvss_max}) dv
|
242
|
+
USING (vulnerability_id)"
|
181
243
|
end
|
182
244
|
|
183
245
|
def self.vulnerable_new_items(options={})
|
246
|
+
# self.send("vulnerable_new_items_per_#{options[:id_type]}", options)
|
247
|
+
|
248
|
+
standard_filter = if options[:id_type] == 'site'
|
249
|
+
"MIN(fasv.scan_id) > #{options[:delta]}"
|
250
|
+
else
|
251
|
+
"MIN(fasv.date) > '#{options[:delta]}'"
|
252
|
+
end
|
253
|
+
|
254
|
+
cve_filter = self.generate_cve_filter(options[:filters][:cve])
|
255
|
+
date_filter = self.generate_date_filter(options[:filters][:date], false)
|
256
|
+
cvss_filter = self.generate_cvss_filter(options[:filters][:cvss])
|
257
|
+
|
184
258
|
"SELECT
|
185
|
-
|
259
|
+
CAST(subq.asset_id as text) Configuration_Item,
|
186
260
|
TRUE as Active,
|
187
261
|
concat('R7_', subq.vulnerability_id) as Vulnerability,
|
188
262
|
fasva.first_discovered as First_Found,
|
@@ -193,17 +267,25 @@ module NexposeServiceNow
|
|
193
267
|
coalesce(NULLIF(favi.name,''), 'None') as Protocol
|
194
268
|
|
195
269
|
FROM (
|
196
|
-
SELECT fasv.asset_id, fasv.vulnerability_id, vulnerability_instances,
|
197
|
-
MIN(fasv.scan_id) as first_found, MAX(fasv.scan_id) as latest_found,
|
270
|
+
SELECT fasv.asset_id, fasv.vulnerability_id, vulnerability_instances,
|
271
|
+
MIN(fasv.scan_id) as first_found, MAX(fasv.scan_id) as latest_found,
|
198
272
|
s.current_scan, s.host_name, s.ip_address
|
199
273
|
FROM fact_asset_scan_vulnerability_finding fasv
|
274
|
+
|
275
|
+
#{cve_filter}
|
276
|
+
#{cvss_filter}
|
277
|
+
|
200
278
|
JOIN (
|
201
279
|
SELECT asset_id, host_name, ip_address, lastScan(asset_id) AS current_scan FROM dim_asset
|
202
280
|
) s ON s.asset_id = fasv.asset_id
|
203
281
|
GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan, s.host_name, s.ip_address, vulnerability_instances
|
204
|
-
HAVING
|
282
|
+
HAVING MAX(fasv.scan_id)=current_scan AND #{standard_filter}
|
205
283
|
) subq
|
206
|
-
|
284
|
+
|
285
|
+
JOIN (select asset_id, vulnerability_id,
|
286
|
+
first_discovered, most_recently_discovered
|
287
|
+
from fact_asset_vulnerability_age
|
288
|
+
#{date_filter}) fasva USING (asset_id, vulnerability_id)
|
207
289
|
|
208
290
|
JOIN (select DISTINCT on(asset_id, vulnerability_id) asset_id, scan_id, vulnerability_id, port, dp.name
|
209
291
|
from fact_asset_vulnerability_instance
|
@@ -212,23 +294,52 @@ module NexposeServiceNow
|
|
212
294
|
end
|
213
295
|
|
214
296
|
def self.vulnerable_old_items(options={})
|
297
|
+
standard_filter = if options[:id_type] == 'site'
|
298
|
+
"MAX(fasv.scan_id) >= #{options[:delta]}"
|
299
|
+
else
|
300
|
+
"MAX(fasv.scan_id) >= scanAsOf(fasv.asset_id, '#{options[:delta]}') AND
|
301
|
+
lastScan(fasv.asset_id) > scanAsOf(fasv.asset_id, '#{options[:delta]}')"
|
302
|
+
end
|
303
|
+
|
304
|
+
cve_filter = self.generate_cve_filter(options[:filters][:cve])
|
305
|
+
date_filter = self.generate_date_filter(options[:filters][:date])
|
306
|
+
cvss_filter = self.generate_cvss_filter(options[:filters][:cvss])
|
307
|
+
|
308
|
+
# Only perform this operation is necessary
|
309
|
+
date_field = if date_filter.nil? || date_filter == ''
|
310
|
+
''
|
311
|
+
else
|
312
|
+
'MIN(fasv.scan_id) as first_found,'
|
313
|
+
end
|
314
|
+
|
215
315
|
"SELECT
|
216
|
-
|
316
|
+
CAST(da.asset_id as text) Configuration_Item,
|
217
317
|
FALSE as Active,
|
218
318
|
concat('R7_', subq.vulnerability_id) as Vulnerability,
|
219
319
|
da.ip_address as IP_Address
|
220
320
|
FROM (
|
221
|
-
SELECT fasv.asset_id, fasv.vulnerability_id,
|
321
|
+
SELECT fasv.asset_id, fasv.vulnerability_id,
|
322
|
+
#{date_field}
|
323
|
+
MAX(fasv.scan_id) as latest_found,
|
222
324
|
s.current_scan, s.host_name, s.ip_address
|
223
325
|
FROM fact_asset_scan_vulnerability_finding fasv
|
326
|
+
|
327
|
+
#{cve_filter}
|
328
|
+
#{cvss_filter}
|
329
|
+
|
224
330
|
JOIN (
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
)
|
331
|
+
SELECT asset_id, host_name, ip_address, lastScan(asset_id) AS current_scan FROM dim_asset
|
332
|
+
) s ON s.asset_id = fasv.asset_id
|
333
|
+
GROUP BY fasv.asset_id, fasv.vulnerability_id, s.current_scan, s.host_name, s.ip_address, vulnerability_instances
|
334
|
+
|
335
|
+
HAVING MAX(fasv.scan_id) < current_scan
|
336
|
+
AND #{standard_filter}
|
337
|
+
) subq
|
338
|
+
|
230
339
|
JOIN dim_asset da ON subq.asset_id = da.asset_id
|
340
|
+
#{date_filter}
|
231
341
|
ORDER BY da.ip_address"
|
342
|
+
|
232
343
|
end
|
233
344
|
|
234
345
|
def self.latest_scans(options={})
|
@@ -238,8 +349,8 @@ module NexposeServiceNow
|
|
238
349
|
end
|
239
350
|
|
240
351
|
def self.multiple_reports?(query_name)
|
241
|
-
single_queries =
|
242
|
-
|
352
|
+
single_queries = %w(vulnerabilities vulnerability_category
|
353
|
+
vulnerability_references latest_scans)
|
243
354
|
return !(single_queries.include? query_name.to_s)
|
244
355
|
end
|
245
356
|
end
|