arachni 1.0.4 → 1.0.5

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +8 -4
  4. data/bin/arachni_console +1 -1
  5. data/components/checks/active/no_sql_injection.rb +4 -4
  6. data/components/checks/passive/common_directories/directories.txt +1 -0
  7. data/components/checks/passive/common_files/filenames.txt +1 -0
  8. data/components/plugins/login_script.rb +156 -0
  9. data/components/reporters/plugin_formatters/html/login_script.rb +48 -0
  10. data/components/reporters/plugin_formatters/stdout/login_script.rb +23 -0
  11. data/components/reporters/plugin_formatters/xml/login_script.rb +26 -0
  12. data/components/reporters/xml/schema.xsd +17 -0
  13. data/lib/arachni/browser.rb +7 -4
  14. data/lib/arachni/browser/javascript.rb +40 -4
  15. data/lib/arachni/browser/javascript/proxy.rb +1 -1
  16. data/lib/arachni/browser_cluster/worker.rb +14 -4
  17. data/lib/arachni/check/auditor.rb +24 -7
  18. data/lib/arachni/check/manager.rb +6 -0
  19. data/lib/arachni/framework.rb +54 -6
  20. data/lib/arachni/http/client.rb +41 -23
  21. data/lib/arachni/http/headers.rb +5 -1
  22. data/lib/arachni/http/message.rb +0 -7
  23. data/lib/arachni/http/request.rb +40 -32
  24. data/lib/arachni/http/response.rb +8 -1
  25. data/lib/arachni/platform/manager.rb +7 -0
  26. data/lib/arachni/rpc/server/framework/multi_instance.rb +1 -1
  27. data/lib/arachni/session.rb +88 -58
  28. data/lib/arachni/state/framework.rb +34 -5
  29. data/lib/arachni/support/profiler.rb +2 -0
  30. data/lib/arachni/uri.rb +2 -1
  31. data/lib/version +1 -1
  32. data/spec/arachni/browser/javascript_spec.rb +15 -0
  33. data/spec/arachni/check/manager_spec.rb +17 -0
  34. data/spec/arachni/framework_spec.rb +4 -2
  35. data/spec/arachni/http/client_spec.rb +1 -1
  36. data/spec/arachni/session_spec.rb +80 -37
  37. data/spec/arachni/state/framework_spec.rb +34 -1
  38. data/spec/arachni/uri_spec.rb +7 -0
  39. data/spec/components/plugins/login_script_spec.rb +157 -0
  40. data/spec/support/servers/plugins/login_script.rb +13 -0
  41. data/ui/cli/output.rb +26 -9
  42. metadata +11 -3
@@ -20,7 +20,11 @@ class Headers < Hash
20
20
 
21
21
  # @param [Headers, Hash] headers
22
22
  def initialize( headers = {} )
23
- (headers || {}).each { |k, v| self[k] = v }
23
+ merge!( headers || {} )
24
+ end
25
+
26
+ def merge!( headers )
27
+ headers.each { |k, v| self[k] = v }
24
28
  end
25
29
 
26
30
  # @note `field` will be capitalized appropriately before storing.
@@ -38,7 +38,6 @@ class Message
38
38
  # Body.
39
39
  def initialize( options = {} )
40
40
  options.each do |k, v|
41
- v = my_dup( v )
42
41
  begin
43
42
  send( "#{k}=", v )
44
43
  rescue NoMethodError
@@ -65,12 +64,6 @@ class Message
65
64
  @url = Arachni::URI( url ).to_s.freeze
66
65
  end
67
66
 
68
- private
69
-
70
- def my_dup( value )
71
- value.dup rescue value
72
- end
73
-
74
67
  end
75
68
  end
76
69
  end
@@ -127,10 +127,10 @@ class Request < Message
127
127
  @train = false if @train.nil?
128
128
  @update_cookies = false if @update_cookies.nil?
129
129
  @follow_location = false if @follow_location.nil?
130
- @max_redirects = (Arachni::Options.http.request_redirect_limit || REDIRECT_LIMIT)
130
+ @max_redirects = (Options.http.request_redirect_limit || REDIRECT_LIMIT)
131
131
  @on_complete = []
132
132
 
133
- @timeout ||= Arachni::Options.http.request_timeout
133
+ @timeout ||= Options.http.request_timeout
134
134
  @mode ||= :async
135
135
  @parameters ||= {}
136
136
  @cookies ||= {}
@@ -213,6 +213,10 @@ class Request < Message
213
213
  end.merge( cookies )
214
214
  end
215
215
 
216
+ def effective_parameters
217
+ Utilities.uri_parse_query( url ).merge( parameters || {} )
218
+ end
219
+
216
220
  def body_parameters
217
221
  return {} if method != :post
218
222
  parameters.any? ? parameters : self.class.parse_body( body )
@@ -276,17 +280,10 @@ class Request < Message
276
280
  #
277
281
  # @return [Response]
278
282
  def run
279
- response = to_typhoeus.run
280
- fill_in_data_from_typhoeus_response response
281
-
282
- Response.from_typhoeus( response ).tap { |r| r.request = self }
283
+ client_run.tap { |r| r.request = self }
283
284
  end
284
285
 
285
- def handle_response( response, typhoeus_response = nil )
286
- if typhoeus_response
287
- fill_in_data_from_typhoeus_response typhoeus_response
288
- end
289
-
286
+ def handle_response( response )
290
287
  response.request = self
291
288
  @on_complete.each { |b| b.call response }
292
289
  response
@@ -295,24 +292,15 @@ class Request < Message
295
292
  # @return [Typhoeus::Response]
296
293
  # `self` converted to a `Typhoeus::Request`.
297
294
  def to_typhoeus
298
- headers['Cookie'] = effective_cookies.
299
- map { |k, v| "#{Cookie.encode( k )}=#{Cookie.encode( v )}" }.
300
- join( ';' )
301
-
302
- headers['User-Agent'] ||= Arachni::Options.http.user_agent
303
- headers['Accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
304
- headers['From'] ||= Arachni::Options.authorized_by if Arachni::Options.authorized_by
305
-
306
- headers.delete( 'Cookie' ) if headers['Cookie'].empty?
307
- headers.each { |k, v| headers[k] = Header.encode( v ) if v }
295
+ prepare_headers
308
296
 
309
- if (userpwd = (@username || Arachni::Options.http.authentication_username))
310
- if (passwd = (@password || Arachni::Options.http.authentication_password))
297
+ if (userpwd = (@username || Options.http.authentication_username))
298
+ if (passwd = (@password || Options.http.authentication_password))
311
299
  userpwd += ":#{passwd}"
312
300
  end
313
301
  end
314
302
 
315
- max_size = @response_max_size || Arachni::Options.http.response_max_size
303
+ max_size = @response_max_size || Options.http.response_max_size
316
304
  # Weird I know, for some reason 0 gets ignored.
317
305
  max_size = 1 if max_size == 0
318
306
 
@@ -320,8 +308,7 @@ class Request < Message
320
308
  method: method,
321
309
  headers: headers,
322
310
  body: body,
323
- params: Arachni::Utilities.uri_parse_query( url ).
324
- merge( parameters || {} ),
311
+ params: effective_parameters,
325
312
  userpwd: userpwd,
326
313
  followlocation: follow_location?,
327
314
  maxredirs: @max_redirects,
@@ -350,15 +337,15 @@ class Request < Message
350
337
  options[:proxyuserpwd] = proxy_user_password
351
338
  end
352
339
 
353
- elsif Arachni::Options.http.proxy_host && Arachni::Options.http.proxy_port
340
+ elsif Options.http.proxy_host && Options.http.proxy_port
354
341
  options.merge!(
355
- proxy: "#{Arachni::Options.http.proxy_host}:#{Arachni::Options.http.proxy_port}",
356
- proxytype: (Arachni::Options.http.proxy_type || :http).to_sym
342
+ proxy: "#{Options.http.proxy_host}:#{Options.http.proxy_port}",
343
+ proxytype: (Options.http.proxy_type || :http).to_sym
357
344
  )
358
345
 
359
- if Arachni::Options.http.proxy_username && Arachni::Options.http.proxy_password
346
+ if Options.http.proxy_username && Options.http.proxy_password
360
347
  options[:proxyuserpwd] =
361
- "#{Arachni::Options.http.proxy_username}:#{Arachni::Options.http.proxy_password}"
348
+ "#{Options.http.proxy_username}:#{Options.http.proxy_password}"
362
349
  end
363
350
  end
364
351
 
@@ -367,7 +354,8 @@ class Request < Message
367
354
 
368
355
  if @on_complete.any?
369
356
  r.on_complete do |typhoeus_response|
370
- handle_response Response.from_typhoeus( typhoeus_response ), typhoeus_response
357
+ fill_in_data_from_typhoeus_response typhoeus_response
358
+ handle_response Response.from_typhoeus( typhoeus_response )
371
359
  end
372
360
  end
373
361
 
@@ -461,11 +449,31 @@ class Request < Message
461
449
 
462
450
  private
463
451
 
452
+ def prepare_headers
453
+ headers['Cookie'] = effective_cookies.
454
+ map { |k, v| "#{Cookie.encode( k )}=#{Cookie.encode( v )}" }.
455
+ join( ';' )
456
+
457
+ headers['User-Agent'] ||= Options.http.user_agent
458
+ headers['Accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
459
+ headers['From'] ||= Options.authorized_by if Options.authorized_by
460
+
461
+ headers.delete( 'Cookie' ) if headers['Cookie'].empty?
462
+ headers.each { |k, v| headers[k] = Header.encode( v ) if v }
463
+ end
464
+
464
465
  def fill_in_data_from_typhoeus_response( response )
465
466
  @headers_string = response.debug_info.header_out.first
466
467
  @effective_body = response.debug_info.data_out.first
467
468
  end
468
469
 
470
+ def client_run
471
+ response = to_typhoeus.run
472
+ fill_in_data_from_typhoeus_response response
473
+
474
+ Response.from_typhoeus( response )
475
+ end
476
+
469
477
  end
470
478
  end
471
479
  end
@@ -197,8 +197,15 @@ class Response < Message
197
197
 
198
198
  def self.from_typhoeus( response )
199
199
  redirections = response.redirections.map do |redirect|
200
+ rurl = URI.to_absolute( redirect.headers['Location'],
201
+ response.effective_url )
202
+ rurl ||= response.effective_url
203
+
204
+ # Broken redirection, skip it...
205
+ next if !rurl
206
+
200
207
  new(
201
- url: redirect.headers['Location'] || response.effective_url,
208
+ url: rurl,
202
209
  code: redirect.code,
203
210
  headers: redirect.headers
204
211
  )
@@ -294,6 +294,13 @@ class Manager
294
294
  # @return [Manager]
295
295
  # Platform for the given `uri`
296
296
  def self.[]( uri )
297
+ # If fingerprinting is disabled there's no point in filling the cache
298
+ # with the same object over and over, create an identical one for all
299
+ # URLs and return that always.
300
+ if !Options.fingerprint?
301
+ return @default ||= new( Options.platforms )
302
+ end
303
+
297
304
  return new if !(key = make_key( uri ))
298
305
  synchronize { @platforms[key] ||= new }
299
306
  end
@@ -44,7 +44,7 @@ module MultiInstance
44
44
  def errors( starting_line = 0, &block )
45
45
  return [] if !File.exists? error_logfile
46
46
 
47
- error_strings = IO.read( error_logfile ).split( "\n" )
47
+ error_strings = error_buffer.dup
48
48
 
49
49
  if starting_line != 0
50
50
  error_strings = error_strings[starting_line..-1]
@@ -51,6 +51,9 @@ class Session
51
51
  # @return [Browser]
52
52
  attr_reader :browser
53
53
 
54
+ # @return [Block]
55
+ attr_reader :login_sequence
56
+
54
57
  def clean_up
55
58
  configuration.clear
56
59
  shutdown_browser
@@ -98,7 +101,7 @@ class Session
98
101
  # @return [Bool]
99
102
  # `true` if {#configure configured}, `false` otherwise.
100
103
  def configured?
101
- configuration.any?
104
+ !!@login_sequence || configuration.any?
102
105
  end
103
106
 
104
107
  # Finds a login forms based on supplied location, collection and criteria.
@@ -201,16 +204,95 @@ class Session
201
204
  end
202
205
  end
203
206
 
204
- # Uses the information provided by {#configure} to login.
207
+ # @param [Block] block
208
+ # Login sequence. Must return the resulting {Page}.
209
+ #
210
+ # If a {#browser} is {#has_browser? available} it will be passed to the
211
+ # block.
212
+ def record_login_sequence( &block )
213
+ @login_sequence = block
214
+ end
215
+
216
+ # Uses the information provided by {#configure} or {#login_sequence} to login.
205
217
  #
206
218
  # @return [Page, nil]
207
- # {HTTP::Response} if the login form was submitted successfully,
208
- # `nil` if not {#configured?}.
219
+ # {Page} if the login was successful, `nil` otherwise.
209
220
  #
221
+ # @raise [Error::NotConfigured]
222
+ # If not {#configured?}.
210
223
  # @raise [Error::FormNotFound]
211
224
  # If the form could not be found.
212
225
  def login
213
- fail Error::NotConfigured, 'Please #configure the session first.' if !configured?
226
+ fail Error::NotConfigured, 'Please configure the session first.' if !configured?
227
+
228
+ refresh_browser
229
+
230
+ page = @login_sequence ? login_from_sequence : login_from_configuration
231
+
232
+ if has_browser?
233
+ http.update_cookies browser.cookies
234
+ end
235
+
236
+ page
237
+ end
238
+
239
+ # @param [Block] block
240
+ # Block to be passed the {#browser}.
241
+ def with_browser( &block )
242
+ block.call browser
243
+ end
244
+
245
+ # @param [Hash] http_options
246
+ # HTTP options to use for the check.
247
+ # @param [Block] block
248
+ # If a block has been provided the check will be async and the result will
249
+ # be passed to it, otherwise the method will return the result.
250
+ #
251
+ # @return [Bool, nil]
252
+ # `true` if we're logged-in, `false` otherwise.
253
+ #
254
+ # @raise [Error::NoLoginCheck]
255
+ # If no login-check has been configured.
256
+ def logged_in?( http_options = {}, &block )
257
+ fail Error::NoLoginCheck if !has_login_check?
258
+
259
+ http_options = http_options.merge(
260
+ mode: block_given? ? :async : :sync,
261
+ follow_location: true
262
+ )
263
+
264
+ bool = nil
265
+ http.get( Options.session.check_url, http_options ) do |response|
266
+ bool = !!response.body.match( Options.session.check_pattern )
267
+ block.call( bool ) if block
268
+ end
269
+ bool
270
+ end
271
+
272
+ # @return [Bool]
273
+ # `true` if a login check exists, `false` otherwise.
274
+ def has_login_check?
275
+ !!@login_check || !!(Options.session.check_url && Options.session.check_pattern)
276
+ end
277
+
278
+ # @return [HTTP::Client]
279
+ def http
280
+ HTTP::Client
281
+ end
282
+
283
+ def has_browser?
284
+ Browser.has_executable? && Options.scope.dom_depth_limit > 0
285
+ end
286
+
287
+ private
288
+
289
+ def login_from_sequence
290
+ print_debug "Logging in via sequence: #{@login_sequence}"
291
+ @login_sequence.call browser
292
+ end
293
+
294
+ def login_from_configuration
295
+ print_debug 'Logging in via configuration.'
214
296
 
215
297
  if has_browser?
216
298
  print_debug 'Logging in using browser.'
@@ -222,7 +304,7 @@ class Session
222
304
 
223
305
  # Revert to the Framework DOM Level 1 page handling if no browser
224
306
  # is available.
225
- page = refresh_browser ?
307
+ page = has_browser? ?
226
308
  browser.load( configuration[:url], take_snapshot: false ).to_page :
227
309
  Page.from_url( configuration[:url], precision: 1, http: {
228
310
  update_cookies: true
@@ -261,8 +343,6 @@ class Session
261
343
  print_debug 'Submitting form.'
262
344
  form.submit { |p| page = p }
263
345
  print_debug 'Form submitted.'
264
-
265
- http.update_cookies browser.cookies
266
346
  else
267
347
  page = form.submit(
268
348
  mode: :sync,
@@ -281,56 +361,6 @@ class Session
281
361
  page
282
362
  end
283
363
 
284
- # @param [Block] block
285
- # Block to be passed the {#browser}.
286
- def with_browser( &block )
287
- block.call browser
288
- end
289
-
290
- # @param [Hash] http_options
291
- # HTTP options to use for the check.
292
- # @param [Block] block
293
- # If a block has been provided the check will be async and the result will
294
- # be passed to it, otherwise the method will return the result.
295
- #
296
- # @return [Bool, nil]
297
- # `true` if we're logged-in, `false` otherwise.
298
- #
299
- # @raise [Error::NoLoginCheck]
300
- # If no login-check has been configured.
301
- def logged_in?( http_options = {}, &block )
302
- fail Error::NoLoginCheck if !has_login_check?
303
-
304
- http_options = http_options.merge(
305
- mode: block_given? ? :async : :sync,
306
- follow_location: true
307
- )
308
-
309
- bool = nil
310
- http.get( Options.session.check_url, http_options ) do |response|
311
- bool = !!response.body.match( Options.session.check_pattern )
312
- block.call( bool ) if block
313
- end
314
- bool
315
- end
316
-
317
- # @return [Bool]
318
- # `true` if a login check exists, `false` otherwise.
319
- def has_login_check?
320
- !!(Options.session.check_url && Options.session.check_pattern)
321
- end
322
-
323
- # @return [HTTP::Client]
324
- def http
325
- HTTP::Client
326
- end
327
-
328
- def has_browser?
329
- Browser.has_executable? && Options.scope.dom_depth_limit > 0
330
- end
331
-
332
- private
333
-
334
364
  def shutdown_browser
335
365
  return if !@browser
336
366
 
@@ -44,6 +44,9 @@ class Framework
44
44
  # @return [Support::LookUp::HashSet]
45
45
  attr_reader :url_queue_filter
46
46
 
47
+ # @return [Support::LookUp::HashSet]
48
+ attr_reader :element_pre_check_filter
49
+
47
50
  # @return [Set]
48
51
  attr_reader :browser_skip_states
49
52
 
@@ -71,6 +74,8 @@ class Framework
71
74
  @page_queue_filter = Support::LookUp::HashSet.new( hasher: :persistent_hash )
72
75
  @url_queue_filter = Support::LookUp::HashSet.new( hasher: :persistent_hash )
73
76
 
77
+ @element_pre_check_filter = Support::LookUp::HashSet.new( hasher: :coverage_hash )
78
+
74
79
  @running = false
75
80
  @pre_pause_status = nil
76
81
 
@@ -185,6 +190,25 @@ class Framework
185
190
  @browser_skip_states.merge states
186
191
  end
187
192
 
193
+ # @param [#coverage_hash] e
194
+ #
195
+ # @return [Bool]
196
+ # `true` if the element has already been seen (based on the
197
+ # {#element_pre_check_filter}), `false` otherwise.
198
+ #
199
+ # @see #element_checked
200
+ def element_checked?( e )
201
+ @element_pre_check_filter.include? e
202
+ end
203
+
204
+ # @param [Page] e
205
+ # Element to mark as seen.
206
+ #
207
+ # @see #element_checked?
208
+ def element_checked( e )
209
+ @element_pre_check_filter << e
210
+ end
211
+
188
212
  def running?
189
213
  !!@running
190
214
  end
@@ -377,9 +401,8 @@ class Framework
377
401
 
378
402
  rpc.dump( "#{directory}/rpc/" )
379
403
 
380
- %w(page_queue_filter url_queue_filter browser_skip_states
381
- audited_page_count).each do |attribute|
382
-
404
+ %w(element_pre_check_filter page_queue_filter url_queue_filter
405
+ browser_skip_states audited_page_count).each do |attribute|
383
406
  IO.binwrite( "#{directory}/#{attribute}", Marshal.dump( send(attribute) ) )
384
407
  end
385
408
  end
@@ -389,8 +412,12 @@ class Framework
389
412
 
390
413
  framework.rpc = RPC.load( "#{directory}/rpc/" )
391
414
 
392
- %w(page_queue_filter url_queue_filter browser_skip_states).each do |attribute|
393
- framework.send(attribute).merge Marshal.load( IO.binread( "#{directory}/#{attribute}" ) )
415
+ %w(element_pre_check_filter page_queue_filter url_queue_filter
416
+ browser_skip_states).each do |attribute|
417
+ path = "#{directory}/#{attribute}"
418
+ next if !File.exist?( path )
419
+
420
+ framework.send(attribute).merge Marshal.load( IO.binread( path ) )
394
421
  end
395
422
 
396
423
  framework.audited_page_count = Marshal.load( IO.binread( "#{directory}/audited_page_count" ) )
@@ -400,6 +427,8 @@ class Framework
400
427
  def clear
401
428
  rpc.clear
402
429
 
430
+ @element_pre_check_filter.clear
431
+
403
432
  @page_queue_filter.clear
404
433
  @url_queue_filter.clear
405
434