arachni 0.2.4 → 0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.md +33 -0
- data/README.md +2 -4
- data/Rakefile +15 -4
- data/bin/arachni +0 -0
- data/bin/arachni_web +0 -0
- data/bin/arachni_web_autostart +0 -0
- data/bin/arachni_xmlrpc +0 -0
- data/bin/arachni_xmlrpcd +0 -0
- data/bin/arachni_xmlrpcd_monitor +0 -0
- data/lib/arachni.rb +1 -1
- data/lib/framework.rb +36 -6
- data/lib/http.rb +12 -5
- data/lib/module/auditor.rb +482 -59
- data/lib/module/base.rb +17 -0
- data/lib/module/manager.rb +26 -2
- data/lib/module/trainer.rb +1 -12
- data/lib/module/utilities.rb +12 -0
- data/lib/parser/auditable.rb +8 -3
- data/lib/parser/elements.rb +11 -0
- data/lib/parser/page.rb +3 -1
- data/lib/parser/parser.rb +130 -18
- data/lib/rpc/xml/server/dispatcher.rb +21 -0
- data/lib/spider.rb +141 -82
- data/lib/ui/cli/cli.rb +2 -3
- data/lib/ui/web/addon_manager.rb +273 -0
- data/lib/ui/web/addons/autodeploy.rb +172 -0
- data/lib/ui/web/addons/autodeploy/lib/manager.rb +291 -0
- data/lib/ui/web/addons/autodeploy/views/index.erb +124 -0
- data/lib/ui/web/addons/sample.rb +78 -0
- data/lib/ui/web/addons/sample/views/index.erb +4 -0
- data/lib/ui/web/addons/scheduler.rb +139 -0
- data/lib/ui/web/addons/scheduler/views/index.erb +131 -0
- data/lib/ui/web/addons/scheduler/views/options.erb +93 -0
- data/lib/ui/web/dispatcher_manager.rb +80 -13
- data/lib/ui/web/instance_manager.rb +87 -0
- data/lib/ui/web/scheduler.rb +166 -0
- data/lib/ui/web/server.rb +142 -202
- data/lib/ui/web/server/public/js/jquery-ui-timepicker.js +985 -0
- data/lib/ui/web/server/public/plugins/sample/style.css +0 -0
- data/lib/ui/web/server/public/style.css +42 -0
- data/lib/ui/web/server/views/addon.erb +15 -0
- data/lib/ui/web/server/views/addons.erb +46 -0
- data/lib/ui/web/server/views/dispatchers.erb +1 -1
- data/lib/ui/web/server/views/instance.erb +9 -11
- data/lib/ui/web/server/views/layout.erb +14 -1
- data/lib/ui/web/server/views/welcome.erb +7 -6
- data/lib/ui/web/utilities.rb +134 -0
- data/modules/audit/code_injection_timing.rb +6 -2
- data/modules/audit/code_injection_timing/payloads.txt +2 -2
- data/modules/audit/os_cmd_injection_timing.rb +7 -3
- data/modules/audit/os_cmd_injection_timing/payloads.txt +1 -1
- data/modules/audit/sqli_blind_rdiff.rb +18 -233
- data/modules/audit/sqli_blind_rdiff/payloads.txt +5 -0
- data/modules/audit/sqli_blind_timing.rb +9 -2
- data/path_extractors/anchors.rb +1 -1
- data/path_extractors/forms.rb +1 -1
- data/path_extractors/frames.rb +1 -1
- data/path_extractors/generic.rb +1 -1
- data/path_extractors/links.rb +1 -1
- data/path_extractors/meta_refresh.rb +1 -1
- data/path_extractors/scripts.rb +1 -1
- data/path_extractors/sitemap.rb +1 -1
- data/plugins/proxy/server.rb +3 -2
- data/plugins/waf_detector.rb +0 -3
- metadata +37 -34
- data/lib/anemone/cookie_store.rb +0 -35
- data/lib/anemone/core.rb +0 -371
- data/lib/anemone/exceptions.rb +0 -5
- data/lib/anemone/http.rb +0 -144
- data/lib/anemone/page.rb +0 -338
- data/lib/anemone/page_store.rb +0 -160
- data/lib/anemone/storage.rb +0 -34
- data/lib/anemone/storage/base.rb +0 -75
- data/lib/anemone/storage/exceptions.rb +0 -15
- data/lib/anemone/storage/mongodb.rb +0 -89
- data/lib/anemone/storage/pstore.rb +0 -50
- data/lib/anemone/storage/redis.rb +0 -90
- data/lib/anemone/storage/tokyo_cabinet.rb +0 -57
- data/lib/anemone/tentacle.rb +0 -40
data/lib/module/base.rb
CHANGED
@@ -115,6 +115,19 @@ class Base
|
|
115
115
|
def clean_up( )
|
116
116
|
end
|
117
117
|
|
118
|
+
#
|
119
|
+
# ABSTRACT - OPTIONAL
|
120
|
+
#
|
121
|
+
# Prevents auditting elements that have been previously
|
122
|
+
# logged by any of the modules returned by this method.
|
123
|
+
#
|
124
|
+
# @return [Array] module names
|
125
|
+
#
|
126
|
+
def redundant
|
127
|
+
# [ 'sqli', 'sqli_blind_rdiff' ]
|
128
|
+
[]
|
129
|
+
end
|
130
|
+
|
118
131
|
#
|
119
132
|
# ABSTRACT - REQUIRED
|
120
133
|
#
|
@@ -169,6 +182,10 @@ class Base
|
|
169
182
|
Arachni::Module::Manager.register_results( results )
|
170
183
|
end
|
171
184
|
|
185
|
+
def set_framework( framework )
|
186
|
+
@framework = framework
|
187
|
+
end
|
188
|
+
|
172
189
|
end
|
173
190
|
end
|
174
191
|
end
|
data/lib/module/manager.rb
CHANGED
@@ -36,7 +36,7 @@ module Module
|
|
36
36
|
# @author: Tasos "Zapotek" Laskos
|
37
37
|
# <tasos.laskos@gmail.com>
|
38
38
|
# <zapotek@segfault.gr>
|
39
|
-
# @version: 0.1
|
39
|
+
# @version: 0.1.1
|
40
40
|
#
|
41
41
|
class Manager < Arachni::ComponentManager
|
42
42
|
|
@@ -48,7 +48,8 @@ class Manager < Arachni::ComponentManager
|
|
48
48
|
def initialize( opts )
|
49
49
|
super( opts.dir['modules'], Arachni::Modules )
|
50
50
|
@opts = opts
|
51
|
-
@@results
|
51
|
+
@@results = []
|
52
|
+
@@issue_set = Set.new
|
52
53
|
end
|
53
54
|
|
54
55
|
#
|
@@ -60,8 +61,31 @@ class Manager < Arachni::ComponentManager
|
|
60
61
|
#
|
61
62
|
def self.register_results( results )
|
62
63
|
@@results |= results
|
64
|
+
results.each { |issue| @@issue_set << self.issue_set_id_from_issue( issue ) }
|
63
65
|
end
|
64
66
|
|
67
|
+
def self.issue_set_id_from_issue( issue )
|
68
|
+
issue_url = URI( issue.url )
|
69
|
+
issue_url_str = issue_url.scheme + "://" + issue_url.host + issue_url.path
|
70
|
+
return "#{issue.mod_name}:#{issue.elem}:#{issue.var}:#{issue_url_str}"
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.issue_set_id_from_elem( mod_name, elem )
|
74
|
+
elem_url = URI( elem.action )
|
75
|
+
elem_url_str = elem_url.scheme + "://" + elem_url.host + elem_url.path
|
76
|
+
|
77
|
+
return "#{mod_name}:#{elem.type}:#{elem.altered}:#{elem_url_str}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.issue_set
|
81
|
+
@@issue_set
|
82
|
+
end
|
83
|
+
|
84
|
+
def issue_set
|
85
|
+
@@issue_set
|
86
|
+
end
|
87
|
+
|
88
|
+
|
65
89
|
#
|
66
90
|
# Class method
|
67
91
|
#
|
data/lib/module/trainer.rb
CHANGED
@@ -27,6 +27,7 @@ class Trainer
|
|
27
27
|
|
28
28
|
include Output
|
29
29
|
include ElementDB
|
30
|
+
include Utilities
|
30
31
|
|
31
32
|
attr_writer :page
|
32
33
|
attr_accessor :http
|
@@ -70,18 +71,6 @@ class Trainer
|
|
70
71
|
|
71
72
|
end
|
72
73
|
|
73
|
-
#
|
74
|
-
# Decodes URLs to reverse multiple encodes and removes NULL characters
|
75
|
-
#
|
76
|
-
def url_sanitize( url )
|
77
|
-
|
78
|
-
while( url =~ /%/ )
|
79
|
-
url = ( URI.decode( url ).to_s.unpack( 'A*' )[0] )
|
80
|
-
end
|
81
|
-
|
82
|
-
return URI.encode( url )
|
83
|
-
end
|
84
|
-
|
85
74
|
def follow?( url )
|
86
75
|
@parser.url = @page.url
|
87
76
|
|
data/lib/module/utilities.rb
CHANGED
@@ -24,6 +24,18 @@ module Module
|
|
24
24
|
#
|
25
25
|
module Utilities
|
26
26
|
|
27
|
+
#
|
28
|
+
# Decodes URLs to reverse multiple encodes and removes NULL characters
|
29
|
+
#
|
30
|
+
def url_sanitize( url )
|
31
|
+
|
32
|
+
while( url =~ /%/ )
|
33
|
+
url = ( URI.decode( url ).to_s.unpack( 'A*' )[0] )
|
34
|
+
end
|
35
|
+
|
36
|
+
return URI.encode( url )
|
37
|
+
end
|
38
|
+
|
27
39
|
#
|
28
40
|
# Gets path from URL
|
29
41
|
#
|
data/lib/parser/auditable.rb
CHANGED
@@ -49,6 +49,11 @@ class Auditable
|
|
49
49
|
@auditor = auditor
|
50
50
|
end
|
51
51
|
|
52
|
+
def get_auditor
|
53
|
+
@auditor
|
54
|
+
end
|
55
|
+
|
56
|
+
|
52
57
|
#
|
53
58
|
# Delegate output related methods to the auditor
|
54
59
|
#
|
@@ -142,7 +147,7 @@ class Auditable
|
|
142
147
|
return if skip?( elem )
|
143
148
|
|
144
149
|
# inform the user about what we're auditing
|
145
|
-
print_status( get_status_str( opts[:altered] ) )
|
150
|
+
print_status( get_status_str( opts[:altered] ) ) if !opts[:silent]
|
146
151
|
|
147
152
|
# submit the element with the injection values
|
148
153
|
req = elem.submit( opts )
|
@@ -179,7 +184,7 @@ class Auditable
|
|
179
184
|
var_combo = []
|
180
185
|
if( !hash || hash.size == 0 ) then return [] end
|
181
186
|
|
182
|
-
if( self.is_a?( Arachni::Parser::Element::Form ) )
|
187
|
+
if( self.is_a?( Arachni::Parser::Element::Form ) && !opts[:skip_orig] )
|
183
188
|
|
184
189
|
if !audited?( audit_id( Arachni::Parser::Element::Form::FORM_VALUES_ORIGINAL ) )
|
185
190
|
# this is the original hash, in case the default values
|
@@ -295,7 +300,7 @@ class Auditable
|
|
295
300
|
print_error( 'Failed to get responses, backing out... ' )
|
296
301
|
next
|
297
302
|
else
|
298
|
-
print_status( 'Analyzing response #' + res.request.id.to_s + '...' )
|
303
|
+
print_status( 'Analyzing response #' + res.request.id.to_s + '...' ) if elem.opts && !elem.opts[:silent]
|
299
304
|
end
|
300
305
|
|
301
306
|
# call the block, if there's one
|
data/lib/parser/elements.rb
CHANGED
@@ -48,6 +48,8 @@ class Base < Arachni::Element::Auditable
|
|
48
48
|
|
49
49
|
attr_accessor :auditable
|
50
50
|
|
51
|
+
attr_accessor :orig
|
52
|
+
|
51
53
|
#
|
52
54
|
# Relatively 'raw' hash holding the element's attributes, values, etc.
|
53
55
|
#
|
@@ -113,6 +115,8 @@ class Link < Base
|
|
113
115
|
@method = 'get'
|
114
116
|
|
115
117
|
@auditable = @raw['vars']
|
118
|
+
@orig = @auditable.deep_clone
|
119
|
+
@orig.freeze
|
116
120
|
end
|
117
121
|
|
118
122
|
def http_request( url, opts )
|
@@ -155,6 +159,8 @@ class Form < Base
|
|
155
159
|
@method = @raw['attrs']['method']
|
156
160
|
|
157
161
|
@auditable = simple['auditable'] || {}
|
162
|
+
@orig = @auditable.deep_clone
|
163
|
+
@orig.freeze
|
158
164
|
end
|
159
165
|
|
160
166
|
def http_request( url, opts )
|
@@ -248,6 +254,9 @@ class Cookie < Base
|
|
248
254
|
|cookie|
|
249
255
|
Options.instance.exclude_cookies.include?( cookie )
|
250
256
|
}
|
257
|
+
|
258
|
+
@orig = @auditable.deep_clone
|
259
|
+
@orig.freeze
|
251
260
|
end
|
252
261
|
|
253
262
|
def http_request( url, opts )
|
@@ -274,6 +283,8 @@ class Header < Base
|
|
274
283
|
@method = 'header'
|
275
284
|
|
276
285
|
@auditable = @raw
|
286
|
+
@orig = @auditable.deep_clone
|
287
|
+
@orig.freeze
|
277
288
|
end
|
278
289
|
|
279
290
|
def http_request( url, opts )
|
data/lib/parser/page.rb
CHANGED
@@ -19,7 +19,7 @@ class Parser
|
|
19
19
|
# @author: Tasos "Zapotek" Laskos
|
20
20
|
# <tasos.laskos@gmail.com>
|
21
21
|
# <zapotek@segfault.gr>
|
22
|
-
# @version: 0.2
|
22
|
+
# @version: 0.2.1
|
23
23
|
#
|
24
24
|
class Page
|
25
25
|
|
@@ -60,6 +60,8 @@ class Page
|
|
60
60
|
#
|
61
61
|
attr_accessor :response_headers
|
62
62
|
|
63
|
+
attr_accessor :paths
|
64
|
+
|
63
65
|
#
|
64
66
|
# @see Parser#links
|
65
67
|
#
|
data/lib/parser/parser.rb
CHANGED
@@ -10,9 +10,11 @@
|
|
10
10
|
module Arachni
|
11
11
|
|
12
12
|
opts = Arachni::Options.instance
|
13
|
+
require 'webrick'
|
13
14
|
require opts.dir['lib'] + 'parser/elements'
|
14
15
|
require opts.dir['lib'] + 'parser/page'
|
15
16
|
require opts.dir['lib'] + 'module/utilities'
|
17
|
+
require opts.dir['lib'] + 'component_manager'
|
16
18
|
|
17
19
|
#
|
18
20
|
# Analyzer class
|
@@ -41,12 +43,41 @@ require opts.dir['lib'] + 'module/utilities'
|
|
41
43
|
# @author: Tasos "Zapotek" Laskos
|
42
44
|
# <tasos.laskos@gmail.com>
|
43
45
|
# <zapotek@segfault.gr>
|
44
|
-
# @version: 0.2
|
46
|
+
# @version: 0.2.1
|
45
47
|
#
|
46
48
|
class Parser
|
47
|
-
|
49
|
+
include Arachni::UI::Output
|
48
50
|
include Arachni::Module::Utilities
|
49
51
|
|
52
|
+
module Extractors
|
53
|
+
#
|
54
|
+
# Base Spider parser class for modules.
|
55
|
+
#
|
56
|
+
# The aim of such modules is to extract paths from a webpage for the Spider to follow.
|
57
|
+
#
|
58
|
+
#
|
59
|
+
# @author: Tasos "Zapotek" Laskos
|
60
|
+
# <tasos.laskos@gmail.com>
|
61
|
+
# <zapotek@segfault.gr>
|
62
|
+
# @version: 0.1
|
63
|
+
# @abstract
|
64
|
+
#
|
65
|
+
class Paths
|
66
|
+
|
67
|
+
#
|
68
|
+
# This method must be implemented by all modules and must return an array
|
69
|
+
# of paths as plain strings
|
70
|
+
#
|
71
|
+
# @param [Nokogiri] Nokogiri document
|
72
|
+
#
|
73
|
+
# @return [Array<String>] paths
|
74
|
+
#
|
75
|
+
def run( doc )
|
76
|
+
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
50
81
|
#
|
51
82
|
# @return [String] the url of the page
|
52
83
|
#
|
@@ -68,7 +99,7 @@ class Parser
|
|
68
99
|
def initialize( opts, res )
|
69
100
|
@opts = opts
|
70
101
|
|
71
|
-
@url = res.effective_url
|
102
|
+
@url = url_sanitize( res.effective_url )
|
72
103
|
@html = res.body
|
73
104
|
@response_headers = res.headers_hash
|
74
105
|
end
|
@@ -89,6 +120,7 @@ class Parser
|
|
89
120
|
:html => @html,
|
90
121
|
:headers => [],
|
91
122
|
:response_headers => @response_headers,
|
123
|
+
:paths => [],
|
92
124
|
:forms => [],
|
93
125
|
:links => [],
|
94
126
|
:cookies => [],
|
@@ -108,14 +140,25 @@ class Parser
|
|
108
140
|
|
109
141
|
jar = preped.merge( jar )
|
110
142
|
|
143
|
+
c_links = links
|
144
|
+
|
145
|
+
if !( vars = link_vars( @url ) ).empty?
|
146
|
+
url = to_absolute( @url )
|
147
|
+
c_links << Arachni::Parser::Element::Link.new( url, {
|
148
|
+
'href' => url,
|
149
|
+
'vars' => vars
|
150
|
+
} )
|
151
|
+
end
|
152
|
+
|
111
153
|
return Page.new( {
|
112
154
|
:url => @url,
|
113
155
|
:query_vars => link_vars( @url ),
|
114
156
|
:html => @html,
|
115
157
|
:headers => headers(),
|
116
158
|
:response_headers => @response_headers,
|
159
|
+
:paths => paths(),
|
117
160
|
:forms => @opts.audit_forms ? forms() : [],
|
118
|
-
:links => @opts.audit_links ?
|
161
|
+
:links => @opts.audit_links ? c_links : [],
|
119
162
|
:cookies => merge_with_cookiestore( merge_with_cookiejar( cookies_arr ) ),
|
120
163
|
:cookiejar => jar
|
121
164
|
} )
|
@@ -258,7 +301,7 @@ class Parser
|
|
258
301
|
if( !elements[i]['attrs'] || !elements[i]['attrs']['action'] )
|
259
302
|
action = @url.to_s
|
260
303
|
else
|
261
|
-
action = elements[i]['attrs']['action']
|
304
|
+
action = url_sanitize( elements[i]['attrs']['action'] )
|
262
305
|
end
|
263
306
|
action = URI.escape( action ).to_s
|
264
307
|
|
@@ -322,8 +365,17 @@ class Parser
|
|
322
365
|
if( !include?( link['href'] ) ) then next end
|
323
366
|
if !in_domain?( URI.parse( link['href'] ) ) then next end
|
324
367
|
|
325
|
-
link['vars'] =
|
368
|
+
link['vars'] = {}
|
369
|
+
link_vars( link['href'] ).each_pair {
|
370
|
+
|key, val|
|
371
|
+
begin
|
372
|
+
link['vars'][key] = url_sanitize( val )
|
373
|
+
rescue
|
374
|
+
link['vars'][key] = val
|
375
|
+
end
|
376
|
+
}
|
326
377
|
|
378
|
+
link['href'] = url_sanitize( link['href'] )
|
327
379
|
|
328
380
|
link_arr << Element::Link.new( @url, link )
|
329
381
|
|
@@ -356,11 +408,12 @@ class Parser
|
|
356
408
|
rescue
|
357
409
|
end
|
358
410
|
|
411
|
+
|
359
412
|
# don't ask me why....
|
360
413
|
if @response_headers.to_s.substring?( 'set-cookie' )
|
361
414
|
begin
|
362
|
-
cookies << WEBrick::Cookie.parse_set_cookies( @response_headers['Set-Cookie'].to_s )
|
363
|
-
cookies << WEBrick::Cookie.parse_set_cookies( @response_headers['set-cookie'].to_s )
|
415
|
+
cookies << ::WEBrick::Cookie.parse_set_cookies( @response_headers['Set-Cookie'].to_s )
|
416
|
+
cookies << ::WEBrick::Cookie.parse_set_cookies( @response_headers['set-cookie'].to_s )
|
364
417
|
rescue
|
365
418
|
return cookies_arr
|
366
419
|
end
|
@@ -390,6 +443,32 @@ class Parser
|
|
390
443
|
return cookies_arr
|
391
444
|
end
|
392
445
|
|
446
|
+
def dir( url )
|
447
|
+
URI( File.dirname( URI( url.to_s ).path ) + '/' )
|
448
|
+
end
|
449
|
+
|
450
|
+
#
|
451
|
+
# Array of distinct links to follow
|
452
|
+
#
|
453
|
+
# @return [Array<URI>]
|
454
|
+
#
|
455
|
+
def paths
|
456
|
+
return @paths unless @paths.nil?
|
457
|
+
@paths = []
|
458
|
+
return @paths if !doc
|
459
|
+
|
460
|
+
run_extractors( ).each {
|
461
|
+
|path|
|
462
|
+
next if path.nil? or path.empty?
|
463
|
+
abs = to_absolute( path ) rescue next
|
464
|
+
|
465
|
+
@paths << abs if in_domain?( abs )
|
466
|
+
}
|
467
|
+
|
468
|
+
@paths.uniq!
|
469
|
+
return @paths
|
470
|
+
end
|
471
|
+
|
393
472
|
#
|
394
473
|
# Extracts variables and their values from a link
|
395
474
|
#
|
@@ -434,26 +513,36 @@ class Parser
|
|
434
513
|
end
|
435
514
|
rescue Exception => e
|
436
515
|
return nil if link.nil?
|
437
|
-
# return link
|
438
516
|
end
|
439
517
|
|
440
518
|
# remove anchor
|
441
|
-
link = URI.encode( link.to_s.gsub( /#[a-zA-Z0-9_-]*$/,
|
519
|
+
link = URI.encode( link.to_s.gsub( /#[a-zA-Z0-9_-]*$/,'' ) )
|
442
520
|
|
443
|
-
|
444
|
-
|
445
|
-
|
521
|
+
if url = base
|
522
|
+
base_url = URI( url )
|
523
|
+
else
|
524
|
+
base_url = URI( @url )
|
525
|
+
end
|
446
526
|
|
447
|
-
|
527
|
+
relative = URI( link )
|
528
|
+
absolute = base_url.merge( relative )
|
448
529
|
|
449
|
-
|
450
|
-
rescue Exception => e
|
451
|
-
return
|
452
|
-
end
|
530
|
+
absolute.path = '/' if absolute.path && absolute.path.empty?
|
453
531
|
|
454
532
|
return absolute.to_s
|
455
533
|
end
|
456
534
|
|
535
|
+
|
536
|
+
def base
|
537
|
+
begin
|
538
|
+
tmp = doc.search( '//base[@href]' )
|
539
|
+
return tmp[0]['href'].dup
|
540
|
+
rescue
|
541
|
+
return
|
542
|
+
end
|
543
|
+
end
|
544
|
+
|
545
|
+
|
457
546
|
#
|
458
547
|
# Returns +true+ if *uri* is in the same domain as the page, returns
|
459
548
|
# +false+ otherwise
|
@@ -508,6 +597,29 @@ class Parser
|
|
508
597
|
|
509
598
|
private
|
510
599
|
|
600
|
+
#
|
601
|
+
# Runs all Spider (path extraction) modules and returns an array of paths
|
602
|
+
#
|
603
|
+
# @return [Array] paths
|
604
|
+
#
|
605
|
+
def run_extractors
|
606
|
+
lib = @opts.dir['root'] + 'path_extractors/'
|
607
|
+
|
608
|
+
|
609
|
+
begin
|
610
|
+
@@manager ||= ::Arachni::ComponentManager.new( lib, Extractors )
|
611
|
+
|
612
|
+
return @@manager.available.map {
|
613
|
+
|name|
|
614
|
+
@@manager[name].new.run( doc )
|
615
|
+
}.flatten.uniq
|
616
|
+
|
617
|
+
rescue ::Exception => e
|
618
|
+
print_error( e.to_s )
|
619
|
+
print_debug_backtrace( e )
|
620
|
+
end
|
621
|
+
end
|
622
|
+
|
511
623
|
#
|
512
624
|
# Merges an array of form inputs with an array of form selects
|
513
625
|
#
|