CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/lib/rex/parser/nexpose_raw_document.rb
Views: 11777
1
# -*- coding: binary -*-
2
require "rex/parser/nokogiri_doc_mixin"
3
require "date"
4
5
module Rex
6
module Parser
7
8
# If Nokogiri is available, define Template document class.
9
load_nokogiri && class NexposeRawDocument < Nokogiri::XML::SAX::Document
10
11
include NokogiriDocMixin
12
13
attr_reader :tests
14
15
NEXPOSE_HOST_DETAIL_FIELDS = %W{ nx_device_id nx_site_name nx_site_importance nx_scan_template nx_risk_score }
16
NEXPOSE_VULN_DETAIL_FIELDS = %W{
17
nx_scan_id
18
nx_vulnerable_since
19
nx_pci_compliance_status
20
}
21
22
# Triggered every time a new element is encountered. We keep state
23
# ourselves with the @state variable, turning things on when we
24
# get here (and turning things off when we exit in end_element()).
25
def start_element(name=nil,attrs=[])
26
attrs = normalize_attrs(attrs)
27
block = @block
28
@state[:current_tag][name] = true
29
case name
30
when "nodes" # There are two main sections, nodes and VulnerabilityDefinitions
31
@tests = {}
32
when "node"
33
record_host(attrs)
34
when "name"
35
@state[:has_text] = true
36
when "endpoint"
37
@state.delete(:cached_service_object)
38
record_service(attrs)
39
when "service"
40
record_service_info(attrs)
41
when "fingerprint"
42
record_service_fingerprint(attrs)
43
when "os"
44
record_os_fingerprint(attrs)
45
when "test" # All the vulns tested for
46
@state[:has_text] = true
47
record_host_test(attrs)
48
record_service_test(attrs)
49
when "vulnerability"
50
record_vuln(attrs)
51
when "reference"
52
@state[:has_text] = true
53
record_reference(attrs)
54
when "description"
55
@state[:has_text] = true
56
record_vuln_description(attrs)
57
when "solution"
58
@state[:has_text] = true
59
record_vuln_solution(attrs)
60
when "tag"
61
@state[:has_text] = true
62
when "tags"
63
@state[:tags] = []
64
#
65
# These are markup tags only present within description/solutions
66
#
67
when "ContainerBlockElement", # Overall container, no formatting
68
"Paragraph", # <Paragraph preformat="true">
69
"UnorderedList", # List container (bulleted)
70
"ListItem", # List item
71
"URLLink" # <URLLink LinkURL="http://support.microsoft.com/kb/887429" LinkTitle="http://support.microsoft.com/kb/887429" href="http://support.microsoft.com/kb/887429">KB 887429</URLLink>
72
73
record_formatted_content(name, attrs)
74
75
end
76
end
77
78
# When we exit a tag, this is triggered.
79
def end_element(name=nil)
80
block = @block
81
case name
82
when "node" # Wrap it up
83
collect_host_data
84
host_object = report_host &block
85
report_services(host_object)
86
report_fingerprint(host_object)
87
# Reset the state once we close a host
88
@state.delete_if {|k| k.to_s !~ /^(current_tag|in_nodes)$/}
89
@report_data = {:workspace => @args[:workspace]}
90
when "name"
91
collect_hostname
92
@state[:has_text] = false
93
@text = nil
94
when "endpoint"
95
collect_service_data
96
@state.delete(:cached_service_object)
97
when "os"
98
collect_os_fingerprints
99
when "test"
100
report_test(&block)
101
@state[:has_text] = false
102
@text = nil
103
when "vulnerability"
104
collect_vuln_info
105
report_vuln(&block)
106
@state.delete_if {|k| k.to_s !~ /^(current_tag|in_vulndefs)$/}
107
when "reference"
108
@state[:has_text] = false
109
collect_reference
110
@text = nil
111
when "description"
112
@state[:has_text] = false
113
collect_vuln_description
114
@text = nil
115
when "solution"
116
@state[:has_text] = false
117
collect_vuln_solution
118
@text = nil
119
when "tag"
120
@state[:has_text] = false
121
collect_tag
122
@text = nil
123
when "tags"
124
@report_data[:vuln_tags] = @state[:tags]
125
@state.delete(:tags)
126
#
127
# These are markup tags only present within description/solutions
128
#
129
when "ContainerBlockElement", # Overall container, no formatting
130
"Paragraph", # <Paragraph preformat="true">
131
"UnorderedList", # List container (bulleted)
132
"ListItem", # List item
133
"URLLink" # <URLLink LinkURL="http://support.microsoft.com/kb/887429" LinkTitle="http://support.microsoft.com/kb/887429" href="http://support.microsoft.com/kb/887429">KB 887429</URLLink>
134
135
collect_formatted_content(name)
136
end
137
@state[:current_tag].delete name
138
end
139
140
def collect_reference
141
return unless in_tag("references")
142
return unless in_tag("vulnerability")
143
return unless @state[:vuln]
144
@state[:ref][:value] = @text.to_s.strip
145
@report_data[:refs] ||= []
146
@report_data[:refs] << @state[:ref]
147
@state[:ref] = nil
148
end
149
150
def collect_vuln_description
151
return unless in_tag("description")
152
return unless in_tag("vulnerability")
153
return unless @state[:vuln]
154
@report_data[:vuln_description] = clean_formatted_text( @report_data[:vuln_description_stack].join.strip )
155
end
156
157
def collect_vuln_solution
158
return unless in_tag("solution")
159
return unless in_tag("vulnerability")
160
return unless @state[:vuln]
161
@report_data[:vuln_solution] = clean_formatted_text( @report_data[:vuln_solution_stack].join.strip )
162
end
163
164
def collect_tag
165
return unless in_tag("tag")
166
return unless in_tag("tags")
167
return unless in_tag("vulnerability")
168
return unless @state[:vuln]
169
@state[:tags] ||= []
170
@state[:tags] << @text.to_s.strip
171
end
172
173
def collect_vuln_info
174
return unless in_tag("VulnerabilityDefinitions")
175
return unless in_tag("vulnerability")
176
return unless @state[:vuln]
177
vuln = @state[:vuln]
178
vuln[:refs] = @report_data[:refs]
179
@report_data[:vuln] = vuln
180
@state[:vuln] = nil
181
@report_data[:refs] = nil
182
end
183
184
def report_vuln(&block)
185
return unless in_tag("VulnerabilityDefinitions")
186
return unless @report_data[:vuln]
187
return unless @report_data[:vuln][:matches].kind_of? Array
188
189
::ApplicationRecord.connection_pool.with_connection {
190
191
refs = normalize_references(@report_data[:vuln][:refs])
192
refs << "NEXPOSE-#{report_data[:vuln]["id"]}"
193
vuln_instances = @report_data[:vuln][:matches].size
194
db.emit(:vuln, [refs.last,vuln_instances], &block) if block
195
196
# TODO: potential remove the size limit on this field, might require
197
# some additional UX
198
if @report_data[:vuln]['title'].length > 255
199
db.emit :warning, 'Vulnerability name longer than 255 characters, truncating.', &block if block
200
@report_data[:vuln]['title'] = @report_data[:vuln]['title'][0..254]
201
end
202
203
vuln_ids = @report_data[:vuln][:matches].map{ |v| v[0] }
204
vdet_ids = @report_data[:vuln][:matches].map{ |v| v[1] }
205
206
refs = refs.uniq.map{|x| db.find_or_create_ref(:name => x) }
207
208
# Assign title and references to all vuln_ids
209
# Mass update fails due to the join table || ::Mdm::Vuln.where(:id => vuln_ids).update_all({ :name => @report_data[:vuln]["title"], :refs => refs } )
210
vuln_ids.each do |vid|
211
vuln = ::Mdm::Vuln.find(vid)
212
next unless vuln
213
vuln.name = @report_data[:vuln]["title"]
214
215
if refs.length > 0
216
vuln.refs += refs
217
end
218
219
if vuln.changed?
220
vuln.save!
221
end
222
end
223
224
# Mass update vulnerability details across the database based on conditions
225
vdet_info = { :title => @report_data[:vuln]["title"] }
226
vdet_info[:description] = @report_data[:vuln_description] unless @report_data[:vuln_description].to_s.empty?
227
vdet_info[:solution] = @report_data[:vuln_solution] unless @report_data[:vuln_solution].to_s.empty?
228
vdet_info[:nx_tags] = @report_data[:vuln_tags].sort.uniq.join(", ") if ( @report_data[:vuln_tags].kind_of?(::Array) and @report_data[:vuln_tags].length > 0 )
229
vdet_info[:nx_severity] = @report_data[:vuln]["severity"].to_f if @report_data[:vuln]["severity"]
230
vdet_info[:nx_pci_severity] = @report_data[:vuln]["pciSeverity"].to_f if @report_data[:vuln]["pciSeverity"]
231
vdet_info[:cvss_score] = @report_data[:vuln]["cvssScore"].to_f if @report_data[:vuln]["cvssScore"]
232
vdet_info[:cvss_vector] = @report_data[:vuln]["cvssVector"] if @report_data[:vuln]["cvssVector"]
233
234
%W{ published added modified }.each do |tf|
235
next if not @report_data[:vuln][tf]
236
ts = DateTime.parse(@report_data[:vuln][tf]) rescue nil
237
next if not ts
238
vdet_info[ "nx_#{tf}".to_sym ] = ts
239
end
240
241
::Mdm::VulnDetail.where(:id => vdet_ids).update_all(vdet_info)
242
243
@report_data[:vuln] = nil
244
245
}
246
end
247
248
def record_reference(attrs)
249
return unless in_tag("VulnerabilityDefinitions")
250
return unless in_tag("vulnerability")
251
@state[:ref] = attr_hash(attrs)
252
end
253
254
def record_vuln(attrs)
255
return unless in_tag("VulnerabilityDefinitions")
256
vuln = attr_hash(attrs)
257
matching_tests = @tests[ vuln["id"].downcase ]
258
return unless matching_tests
259
return if matching_tests.empty?
260
@state[:vuln] = vuln
261
@state[:vuln][:matches] = matching_tests
262
end
263
264
def record_vuln_description(attrs)
265
@report_data[:vuln_description_stack] = []
266
end
267
268
def record_vuln_solution(attrs)
269
@report_data[:vuln_solution_stack] = []
270
end
271
272
273
def record_formatted_content(name, eattrs)
274
attrs = attr_hash(eattrs)
275
stack = nil
276
277
if in_tag("solution")
278
stack = @report_data[:vuln_solution_stack]
279
end
280
281
if in_tag("description")
282
stack = @report_data[:vuln_description_stack]
283
end
284
285
if in_tag("test")
286
stack = @report_data[:vuln_proof_stack]
287
end
288
289
return if not stack
290
291
@report_data[:formatted_indent] ||= 0
292
293
data = @text.to_s.strip.split(/\n+/).map{|t| t.strip}.join(" ")
294
@text = ""
295
296
case name
297
when 'ListItem'
298
@report_data[:formatted_indent] = 1
299
# data = "\n* " + data
300
when 'URLLink'
301
@report_data[:formatted_link] = attrs["LinkURL"]
302
else
303
304
if @report_data[:formatted_indent] > 1
305
data = (" " * (@report_data[:formatted_indent])) + data
306
end
307
308
if @report_data[:formatted_indent] == 1
309
@report_data[:formatted_indent] = 6
310
end
311
end
312
313
if data.length > 0
314
stack << data
315
end
316
end
317
318
def collect_formatted_content(name)
319
stack = nil
320
prefix = ""
321
322
if in_tag("solution")
323
stack = @report_data[:vuln_solution_stack]
324
end
325
326
if in_tag("description")
327
stack = @report_data[:vuln_description_stack]
328
end
329
330
if in_tag("test")
331
stack = @report_data[:vuln_proof_stack]
332
end
333
334
return if not stack
335
336
data = @text.to_s.strip.split(/\n+/).map{|t| t.strip}.join(" ")
337
@text = ""
338
339
case name
340
when 'URLLink'
341
if @report_data[:formatted_link]
342
if data != @report_data[:formatted_link]
343
if data.empty?
344
data << (" " + @report_data[:formatted_link])
345
else
346
data = " " + data + " ( " + @report_data[:formatted_link] + " )"
347
end
348
end
349
end
350
when 'Paragraph'
351
data << "\n\n"
352
when 'ListItem'
353
@report_data[:formatted_indent] = 0
354
data << "\n"
355
end
356
357
if data.length > 0
358
stack << data
359
end
360
end
361
362
# XML Export 2.0 includes additional test keys:
363
# <test id="unix-unowned-files-or-dirs" status="vulnerable-exploited" scan-id="6381" vulnerable-since="20120322T124352665" pci-compliance-status="pass">
364
365
def report_test
366
return unless in_tag("nodes")
367
return unless in_tag("node")
368
return unless @state[:test]
369
370
vuln_info = {
371
:workspace => @args[:workspace],
372
# This name will be overwritten during the vuln definition
373
# parsing via mass-update.
374
:name => "NEXPOSE-" + @state[:test][:id].downcase,
375
:host => @state[:cached_host_object] || @state[:address]
376
}
377
378
if in_tag("endpoint") and @state[:test][:port]
379
# Verify this port actually has some relation to our tracked state
380
# since it may not due to greedy vulnerability matching
381
if @state[:cached_service_object] and @state[:cached_service_object].port.to_i == @state[:test][:port].to_i
382
vuln_info[:service] = @state[:cached_service_object]
383
else
384
vuln_info[:port] = @state[:test][:port]
385
vuln_info[:proto] = @state[:test][:protocol] if @state[:test][:protocol]
386
end
387
end
388
389
# This hash feeds a vuln_details row for this vulnerability
390
vdet = { :src => 'nexpose', :nx_vuln_id => @state[:test][:id] }
391
392
# This hash defines the matching criteria to overwrite an existing entry
393
vkey = { :src => 'nexpose', :nx_vuln_id => @state[:test][:id] }
394
395
if @state[:nx_device_id]
396
vdet[:nx_device_id] = @state[:nx_device_id]
397
vkey[:nx_device_id] = @state[:nx_device_id]
398
end
399
400
if @state[:test][:key]
401
vdet[:nx_proof_key] = @state[:test][:key]
402
vkey[:nx_proof_key] = @state[:test][:key]
403
end
404
405
vdet[:nx_console_id] = @nx_console_id if @nx_console_id
406
vdet[:nx_vuln_status] = @state[:test][:status] if @state[:test][:status]
407
408
vdet[:nx_scan_id] = @state[:test][:nx_scan_id] if @state[:test][:nx_scan_id]
409
vdet[:nx_pci_compliance_status] = @state[:test][:nx_pci_compliance_status] if @state[:test][:nx_pci_compliance_status]
410
411
if @state[:test][:nx_vulnerable_since]
412
ts = ::DateTime.parse(@state[:test][:nx_vulnerable_since]) rescue nil
413
vdet[:nx_vulnerable_since] = ts if ts
414
end
415
416
proof = clean_formatted_text(@report_data[:vuln_proof_stack].join.strip)
417
@report_data[:vuln_proof_stack] = []
418
419
vuln_info[:info] = proof
420
vdet[:proof] = proof
421
422
# Configure the find key for vuln_details
423
vdet[:key] = vkey
424
425
# Pass this key to the vuln hash to find existing entries
426
# that may have been renamed (re-import nexpose vulns)
427
vuln_info[:details_match] = vkey
428
429
::ApplicationRecord.connection_pool.with_connection {
430
431
# Report the vulnerability
432
vuln = db.report_vuln(vuln_info)
433
434
if vuln
435
# Report the vulnerability details
436
detail = db.report_vuln_details(vuln, vdet)
437
438
# Cache returned host and service objects if necessary
439
@state[:cached_host_object] ||= vuln.host
440
441
# The vuln.service may be found via greedy matching
442
if in_tag("endpoint") and vuln.service
443
@state[:cached_service_object] ||= vuln.service
444
end
445
446
# Record the ID of this vuln for a future mass update that
447
# brings in title, risk, description, solution, etc
448
@tests[ @state[:test][:id].downcase ] ||= []
449
@tests[ @state[:test][:id].downcase ] << [ vuln.id, detail.id ]
450
end
451
452
}
453
@state[:test] = nil
454
end
455
456
def record_os_fingerprint(attrs)
457
return unless in_tag("nodes")
458
return unless in_tag("fingerprints")
459
return unless in_tag("node")
460
return if in_tag("service")
461
@state[:os] = attr_hash(attrs)
462
end
463
464
# Just keep the highest scoring, which is usually the most vague. :(
465
def collect_os_fingerprints
466
@report_data[:os] ||= {}
467
return unless @state[:os]["certainty"].to_f > 0
468
return if @report_data[:os]["os_certainty"].to_f > @state[:os]["certainty"].to_f
469
@report_data[:os] = {} # Zero it out if we're replacing it.
470
@report_data[:os]["os_certainty"] = @state[:os]["certainty"]
471
@report_data[:os]["os_vendor"] = @state[:os]["vendor"]
472
@report_data[:os]["os_family"] = @state[:os]["family"]
473
@report_data[:os]["os_product"] = @state[:os]["product"]
474
@report_data[:os]["os_version"] = @state[:os]["version"]
475
@report_data[:os]["os_arch"] = @state[:os]["arch"]
476
end
477
478
# Just taking the first one.
479
def collect_hostname
480
if in_tag("node")
481
@state[:hostname] ||= @text.to_s.strip if @text
482
@text = nil
483
end
484
end
485
486
def record_service_fingerprint(attrs)
487
return unless in_tag("nodes")
488
return unless in_tag("node")
489
return unless in_tag("service")
490
return unless in_tag("fingerprint")
491
@state[:service_fingerprint] = attr_hash(attrs)
492
end
493
494
def record_service_info(attrs)
495
return unless in_tag("nodes")
496
return unless in_tag("node")
497
return unless in_tag("service")
498
@state[:service].merge! attr_hash(attrs)
499
end
500
501
def report_fingerprint(host_object)
502
return unless host_object.kind_of? ::Mdm::Host
503
return unless @report_data[:os].kind_of? Hash
504
note = {
505
:workspace => host_object.workspace,
506
:host => host_object,
507
:type => "host.os.nexpose_fingerprint",
508
:data => {
509
:family => @report_data[:os]["os_family"],
510
:certainty => @report_data[:os]["os_certainty"]
511
}
512
}
513
note[:data][:vendor] = @report_data[:os]["os_vendor"] if @report_data[:os]["os_vendor"]
514
note[:data][:product] = @report_data[:os]["os_product"] if @report_data[:os]["os_product"]
515
note[:data][:version] = @report_data[:os]["os_version"] if @report_data[:os]["os_version"]
516
note[:data][:arch] = @report_data[:os]["os_arch"] if @report_data[:os]["os_arch"]
517
db_report(:note, note)
518
end
519
520
def report_services(host_object)
521
return unless host_object.kind_of? ::Mdm::Host
522
return unless @report_data[:ports]
523
return if @report_data[:ports].empty?
524
reported = []
525
@report_data[:ports].each do |svc|
526
reported << db_report(:service, svc.merge(:host => host_object))
527
end
528
reported
529
end
530
531
def record_service(attrs)
532
return unless in_tag("nodes")
533
return unless in_tag("node")
534
return unless in_tag("endpoint")
535
@state[:service] = attr_hash(attrs)
536
end
537
538
def collect_service_data
539
return unless in_tag("node")
540
return unless in_tag("endpoint")
541
port_hash = {}
542
@report_data[:ports] ||= []
543
@state[:service].each do |k,v|
544
case k
545
when "protocol"
546
port_hash[:proto] = v
547
when "port"
548
port_hash[:port] = v
549
when "status"
550
port_hash[:status] = (v == "open" ? Msf::ServiceState::Open : Msf::ServiceState::Closed)
551
end
552
end
553
if @state[:service]
554
if state[:service]["name"] == "<unknown>"
555
sname = nil
556
else
557
sname = db.service_name_map(@state[:service]["name"])
558
end
559
port_hash[:name] = sname
560
end
561
if @state[:service_fingerprint]
562
info = []
563
info << @state[:service_fingerprint]["product"] if @state[:service_fingerprint]["product"]
564
info << @state[:service_fingerprint]["version"] if @state[:service_fingerprint]["version"]
565
port_hash[:info] = info.join(" ") if info[0]
566
end
567
@report_data[:ports] << port_hash.clone
568
@state.delete :service_fingerprint
569
@state.delete :service
570
@report_data[:ports]
571
end
572
573
def actually_vulnerable(test)
574
return false unless test.has_key? "status"
575
return false unless test.has_key? "id"
576
['vulnerable-exploited', 'vulnerable-version', 'potential'].include? test["status"]
577
end
578
579
def record_host_test(attrs)
580
return unless in_tag("nodes")
581
return unless in_tag("node")
582
return if in_tag("service")
583
return unless in_tag("tests")
584
585
test = attr_hash(attrs)
586
return unless actually_vulnerable(test)
587
@state[:test] = {:id => test["id"].downcase}
588
@state[:test][:key] = test["key"] if test["key"]
589
@state[:test][:nx_scan_id] = test["scan-id"] if test["scan-id"]
590
@state[:test][:nx_vulnerable_since] = test["vulnerable-since"] if test["vulnerable-since"]
591
@state[:test][:nx_pci_compliance_status] = test["pci-compliance-status"] if test["pci-compliance-status"]
592
593
@report_data[:vuln_proof_stack] = []
594
end
595
596
def record_service_test(attrs)
597
return unless in_tag("nodes")
598
return unless in_tag("node")
599
return unless in_tag("service")
600
return unless in_tag("tests")
601
test = attr_hash(attrs)
602
return unless actually_vulnerable(test)
603
@state[:test] = {
604
:id => test["id"].downcase,
605
:port => @state[:service]["port"],
606
:protocol => @state[:service]["protocol"],
607
}
608
@state[:test][:key] = test["key"] if test["key"]
609
@state[:test][:status] = test["status"] if test["status"]
610
@state[:test][:nx_scan_id] = test["scan-id"] if test["scan-id"]
611
@state[:test][:nx_vulnerable_since] = test["vulnerable-since"] if test["vulnerable-since"]
612
@state[:test][:nx_pci_compliance_status] = test["pci-compliance-status"] if test["pci-compliance-status"]
613
@report_data[:vuln_proof_stack] = []
614
end
615
616
def record_host(attrs)
617
return unless in_tag("nodes")
618
host_attrs = attr_hash(attrs)
619
if host_attrs["status"] == "alive"
620
@state[:host_is_alive] = true
621
@state[:address] = host_attrs["address"]
622
@state[:mac] = host_attrs["hardware-address"] if host_attrs["hardware-address"]
623
624
NEXPOSE_HOST_DETAIL_FIELDS.each do |f|
625
fs = f.to_sym
626
fk = f.sub(/^nx_/, '').gsub('_', '-')
627
if host_attrs[fk]
628
@state[fs] = host_attrs[fk]
629
end
630
end
631
end
632
end
633
634
def collect_host_data
635
return unless in_tag("node")
636
@report_data[:host] = @state[:address]
637
@report_data[:state] = Msf::HostState::Alive
638
@report_data[:name] = @state[:hostname] if @state[:hostname]
639
if @state[:mac]
640
if @state[:mac] =~ /[0-9a-fA-f]{12}/
641
@report_data[:mac] = @state[:mac].scan(/.{2}/).join(":")
642
else
643
@report_data[:mac] = @state[:mac]
644
end
645
end
646
647
NEXPOSE_HOST_DETAIL_FIELDS.each do |f|
648
v = @state[f.to_sym]
649
@report_data[f.to_sym] = v if v
650
end
651
end
652
653
def report_host(&block)
654
if host_is_okay
655
db.emit(:address,@report_data[:host],&block) if block
656
device_id = @report_data[:nx_device_id]
657
658
host_object = db_report(:host, @report_data.merge(:workspace => @args[:workspace] ) )
659
if host_object
660
db.report_import_note(host_object.workspace, host_object)
661
if device_id
662
detail = {
663
:key => { :src => 'nexpose' },
664
:src => 'nexpose',
665
:nx_device_id => device_id
666
}
667
detail[:nx_console_id] = @nx_console_id if @nx_console_id
668
669
NEXPOSE_HOST_DETAIL_FIELDS.each do |f|
670
v = @report_data.delete(f.to_sym)
671
detail[f.to_sym] = v if v
672
end
673
674
675
db.report_host_details(host_object, detail)
676
end
677
end
678
host_object
679
end
680
end
681
682
def clean_formatted_text(txt)
683
txt.split(/\n/).map{ |t|
684
t.sub(/^\s+$/, '').
685
sub(/^(\s{6,20})/, ' ')
686
}.join("\n").gsub(/\n{4,10}/, "\n\n\n")
687
end
688
689
end
690
691
end
692
end
693
694
695