diff --git a/.dockerignore b/.dockerignore index abf6bb87..9db627ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,21 +1,21 @@ -git/ -bundle/ -.idea/ -.yardoc/ -cache/ -coverage/ -spec/ -dev/ .* -**/*.md +bin/ +dev/ +spec/ *.md Dockerfile + +## TEMP +.idea/ +.yardoc/ +bundle/ +cache/ +coverage/ +git/ +**/*.md **/*.orig *.orig CREDITS data.zip DISCLAIMER.txt example.conf.json -bin/ -log.txt - diff --git a/.gitignore b/.gitignore index 0f7866fb..ee7fb0af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,21 @@ +# WPScan (If not using ~/.wpscan/) +cache/ +data/ +log.txt +output.txt + +# WPScan (Deployment) +debug.log +rspec_results.html +wordlist.txt + +# OS/IDE Rubbish +coverage/ +.yardoc/ +.idea/ +*.sublime-* +.*.swp .ash_history -cache -coverage .bundle .DS_Store -.DS_Store? -*.sublime-* -.idea -.*.swp -log.txt -.yardoc -debug.log -wordlist.txt -rspec_results.html -data/ -vendor/ +.DS_Store? \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 00cdf5fe..e79a6973 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ before_install: - "gem regenerate_binstubs" - "bundle --version" before_script: - - "unzip -o $TRAVIS_BUILD_DIR/data.zip -d $TRAVIS_BUILD_DIR" + - "unzip -o $TRAVIS_BUILD_DIR/data.zip -d $HOME/.wpscan/" script: - "bundle exec rspec" notifications: diff --git a/DISCLAIMER.txt b/DISCLAIMER.md similarity index 100% rename from DISCLAIMER.txt rename to DISCLAIMER.md diff --git a/Dockerfile b/Dockerfile index b3390607..4af7010b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,37 @@ FROM ruby:2.5-alpine -MAINTAINER WPScan Team +LABEL maintainer="WPScan Team " ARG BUNDLER_ARGS="--jobs=8 --without test" +# Add a new user RUN adduser -h /wpscan -g WPScan -D wpscan + +# Setup gems RUN echo "gem: --no-ri --no-rdoc" > /etc/gemrc COPY Gemfile /wpscan COPY Gemfile.lock /wpscan -# runtime dependencies +# Runtime dependencies RUN apk add --no-cache libcurl procps && \ # build dependencies apk add --no-cache --virtual build-deps alpine-sdk ruby-dev libffi-dev zlib-dev && \ bundle install --system --gemfile=/wpscan/Gemfile $BUNDLER_ARGS && \ apk del --no-cache build-deps +# Copy over data & set permissions COPY . /wpscan RUN chown -R wpscan:wpscan /wpscan -USER wpscan - -RUN /wpscan/wpscan.rb --update --verbose --no-color - +# Switch directory WORKDIR /wpscan +# Switch users +USER wpscan + +# Update WPScan +RUN /wpscan/wpscan.rb --update --verbose --no-color + +# Run WPScan ENTRYPOINT ["/wpscan/wpscan.rb"] CMD ["--help"] diff --git a/Gemfile b/Gemfile index fed1f734..03bc4df5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,11 +1,12 @@ source 'https://rubygems.org' -gem 'typhoeus', '>=1.1.2' -gem 'nokogiri', '>=1.7.0.1' gem 'addressable', '>=2.5.0' -gem 'yajl-ruby', '>=1.3.0' # Better JSON parser regarding memory usage -gem 'terminal-table', '>=1.6.0' +gem 'nokogiri', '>=1.7.0.1' gem 'ruby-progressbar', '>=1.8.1' +gem 'rubyzip', '>=1.2.1' +gem 'terminal-table', '>=1.6.0' +gem 'typhoeus', '>=1.1.2' +gem 'yajl-ruby', '>=1.3.0' # Better JSON parser regarding memory usage group :test do gem 'webmock', '>=2.3.2' diff --git a/Gemfile.lock b/Gemfile.lock index dcb67560..fbff01db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,7 @@ GEM rspec-support (~> 3.7.0) rspec-support (3.7.1) ruby-progressbar (1.9.0) + rubyzip (1.2.1) safe_yaml (1.0.4) simplecov (0.16.1) docile (~> 1.1) @@ -59,6 +60,7 @@ DEPENDENCIES rspec (>= 3.5.0) rspec-its (>= 1.2.0) ruby-progressbar (>= 1.8.1) + rubyzip (>= 1.2.1) simplecov (>= 0.13.0) terminal-table (>= 1.6.0) typhoeus (>= 1.1.2) diff --git a/data/.gitignore b/data/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/lib/common/cache_file_store.rb b/lib/common/cache_file_store.rb index 09af0714..98c0f331 100644 --- a/lib/common/cache_file_store.rb +++ b/lib/common/cache_file_store.rb @@ -26,6 +26,10 @@ class CacheFileStore unless Dir.exist?(@storage_path) FileUtils.mkdir_p(@storage_path) end + + unless Pathname.new(@storage_path).writable? + fail "#{@storage_path} is not writable" + end end def clean diff --git a/lib/common/collections/wp_items/detectable.rb b/lib/common/collections/wp_items/detectable.rb index e0749f37..bca5cd6c 100644 --- a/lib/common/collections/wp_items/detectable.rb +++ b/lib/common/collections/wp_items/detectable.rb @@ -95,7 +95,7 @@ class WpItems < Array code = tag.text.to_s next if code.empty? - if ! code.valid_encoding? + if !code.valid_encoding? code = code.encode('UTF-16be', :invalid => :replace, :replace => '?').encode('UTF-8') end diff --git a/lib/common/collections/wp_users/output.rb b/lib/common/collections/wp_users/output.rb index c7000dbb..b6516c1a 100644 --- a/lib/common/collections/wp_users/output.rb +++ b/lib/common/collections/wp_users/output.rb @@ -9,7 +9,7 @@ class WpUsers < WpItems # @return [ void ] def output(options = {}) rows = [] - headings = ['Id', 'Login', 'Name'] + headings = ['ID', 'Login', 'Name'] headings << 'Password' if options[:show_password] remove_junk_from_display_names diff --git a/lib/common/common_helper.rb b/lib/common/common_helper.rb index 47f071c3..5431ef2a 100644 --- a/lib/common/common_helper.rb +++ b/lib/common/common_helper.rb @@ -1,32 +1,30 @@ # encoding: UTF-8 -LIB_DIR = File.expand_path(File.join(__dir__, '..')) -ROOT_DIR = File.expand_path(File.join(LIB_DIR, '..')) # expand_path is used to get "wpscan/" instead of "wpscan/lib/../" -DATA_DIR = File.join(ROOT_DIR, 'data') -CONF_DIR = File.join(ROOT_DIR, 'conf') -CACHE_DIR = File.join(ROOT_DIR, 'cache') -WPSCAN_LIB_DIR = File.join(LIB_DIR, 'wpscan') -UPDATER_LIB_DIR = File.join(LIB_DIR, 'updater') -COMMON_LIB_DIR = File.join(LIB_DIR, 'common') -MODELS_LIB_DIR = File.join(COMMON_LIB_DIR, 'models') -COLLECTIONS_LIB_DIR = File.join(COMMON_LIB_DIR, 'collections') +# Location directories +LIB_DIR = File.expand_path(File.join(__dir__, '..')) # wpscan/lib/ +ROOT_DIR = File.expand_path(File.join(LIB_DIR, '..')) # wpscan/ - expand_path is used to get "wpscan/" instead of "wpscan/lib/../" +USER_DIR = File.expand_path(Dir.home) # ~/ -DEFAULT_LOG_FILE = File.join(ROOT_DIR, 'log.txt') +# Core WPScan directories +CACHE_DIR = File.join(USER_DIR, '.wpscan/cache') # ~/.wpscan/cache/ +DATA_DIR = File.join(USER_DIR, '.wpscan/data') # ~/.wpscan/data/ +CONF_DIR = File.join(USER_DIR, '.wpscan/conf') # ~/.wpscan/conf/ - Not used ATM (only ref via ./spec/ for travis) +COMMON_LIB_DIR = File.join(LIB_DIR, 'common') # wpscan/lib/common/ +WPSCAN_LIB_DIR = File.join(LIB_DIR, 'wpscan') # wpscan/lib/wpscan/ +MODELS_LIB_DIR = File.join(COMMON_LIB_DIR, 'models') # wpscan/lib/common/models/ -# Plugins directories -COMMON_PLUGINS_DIR = File.join(COMMON_LIB_DIR, 'plugins') -WPSCAN_PLUGINS_DIR = File.join(WPSCAN_LIB_DIR, 'plugins') # Not used ATM +# Core WPScan files +DEFAULT_LOG_FILE = File.join(USER_DIR, '.wpscan/log.txt') # ~/.wpscan/log.txt +DATA_FILE = File.join(ROOT_DIR, 'data.zip') # wpscan/data.zip -# Data files -WORDPRESSES_FILE = File.join(DATA_DIR, 'wordpresses.json') -PLUGINS_FILE = File.join(DATA_DIR, 'plugins.json') -THEMES_FILE = File.join(DATA_DIR, 'themes.json') -WP_VERSIONS_FILE = File.join(DATA_DIR, 'wp_versions.xml') -LOCAL_FILES_FILE = File.join(DATA_DIR, 'local_vulnerable_files.xml') -WP_VERSIONS_XSD = File.join(DATA_DIR, 'wp_versions.xsd') -LOCAL_FILES_XSD = File.join(DATA_DIR, 'local_vulnerable_files.xsd') -USER_AGENTS_FILE = File.join(DATA_DIR, 'user-agents.txt') -LAST_UPDATE_FILE = File.join(DATA_DIR, '.last_update') +# WPScan Data files (data.zip) +LAST_UPDATE_FILE = File.join(DATA_DIR, '.last_update') # ~/.wpscan/data/.last_update +PLUGINS_FILE = File.join(DATA_DIR, 'plugins.json') # ~/.wpscan/data/plugins.json +THEMES_FILE = File.join(DATA_DIR, 'themes.json') # ~/.wpscan/data/themes.json +TIMTHUMBS_FILE = File.join(DATA_DIR, 'timthumbs.txt') # ~/.wpscan/data/timthumbs.txt +USER_AGENTS_FILE = File.join(DATA_DIR, 'user-agents.txt') # ~/.wpscan/data/user-agents.txt +WORDPRESSES_FILE = File.join(DATA_DIR, 'wordpresses.json') # ~/.wpscan/data/wordpresses.json +WP_VERSIONS_FILE = File.join(DATA_DIR, 'wp_versions.xml') # ~/.wpscan/data/wp_versions.xml MIN_RUBY_VERSION = '2.1.9' @@ -50,6 +48,7 @@ def windows? end require 'environment' +require 'zip' def escape_glob(s) s.gsub(/[\\\{\}\[\]\*\?]/) { |x| '\\' + x } @@ -78,13 +77,39 @@ def add_trailing_slash(url) url =~ /\/$/ ? url : "#{url}/" end -def missing_db_file? +def missing_db_files? DbUpdater::FILES.each do |db_file| return true unless File.exist?(File.join(DATA_DIR, db_file)) end false end +# Find data.zip? +def has_db_zip? + return File.exist?(DATA_FILE) +end + +# Extract data.zip +def extract_db_zip + # Create data folder + FileUtils.mkdir_p(DATA_DIR) + + Zip::File.open(DATA_FILE) do |zip_file| + zip_file.each do |f| + # Feedback to the user + #puts "[+] Extracting: #{File.basename(f.name)}" + f_path = File.join(DATA_DIR, File.basename(f.name)) + + # Delete if already there + #puts "[+] Deleting: #{File.basename(f.name)}" if File.exist?(f_path) + FileUtils.rm(f_path) if File.exist?(f_path) + + # Extract + zip_file.extract(f, f_path) + end + end +end + def last_update date = nil if File.exists?(LAST_UPDATE_FILE) @@ -94,6 +119,7 @@ def last_update date end +# Was it 5 days ago? def update_required? date = last_update day_seconds = 24 * 60 * 60 @@ -166,6 +192,11 @@ def banner puts end +# Space out sections +def spacer + puts " - - - - -" +end + def xml(file) Nokogiri::XML(File.open(file)) do |config| config.noblanks @@ -253,14 +284,26 @@ end # @return [ String ] A random user-agent from data/user-agents.txt def get_random_user_agent user_agents = [] + + # If we can't access the file, die + raise('[ERROR] Missing user-agent data. Please re-run with just --update.') unless File.exist?(USER_AGENTS_FILE) + + # Read in file f = File.open(USER_AGENTS_FILE, 'r') + + # Read every line in the file f.each_line do |line| - # ignore comments + # Remove any End of Line issues, and leading/trailing spaces + line = line.strip.chomp + # Ignore empty files and comments next if line.empty? or line =~ /^\s*(#|\/\/)/ + # Add to array user_agents << line.strip end + # Close file handler f.close - # return ransom user-agent + + # Return random user-agent user_agents.sample end @@ -274,3 +317,21 @@ end def url_encode(str) CGI.escape(str).gsub("+", "%20") end + +# Check valid JSON? +def valid_json?(json) + JSON.parse(json) + return true + rescue JSON::ParserError => e + return false +end + +# Get the HTTP response code +def get_http_status(url) + Browser.get(url.to_s).code +end + +# Check to see if we need a "s" +def grammar_s(size) + size.to_i >= 2 ? "s" : "" +end \ No newline at end of file diff --git a/lib/common/db_updater.rb b/lib/common/db_updater.rb index f8d2439a..0ad64860 100644 --- a/lib/common/db_updater.rb +++ b/lib/common/db_updater.rb @@ -13,8 +13,13 @@ class DbUpdater def initialize(repo_directory) @repo_directory = repo_directory - fail "#{repo_directory} is not writable" unless \ - Pathname.new(repo_directory).writable? + unless Dir.exist?(@repo_directory) + FileUtils.mkdir_p(@repo_directory) + end + + unless Pathname.new(@repo_directory).writable? + fail "#{@repo_directory} is not writable" + end end # @return [ Hash ] The params for Typhoeus::Request @@ -83,7 +88,7 @@ class DbUpdater def update(verbose = false) FILES.each do |filename| begin - puts "[+] Checking #{filename}" if verbose + puts "[+] Checking: #{filename}" if verbose db_checksum = remote_file_checksum(filename) # Checking if the file needs to be updated @@ -95,7 +100,7 @@ class DbUpdater puts ' [i] Needs to be updated' if verbose create_backup(filename) puts ' [i] Backup Created' if verbose - puts ' [i] Downloading new file' if verbose + puts " [i] Downloading new file: #{remote_file_url(filename)}" if verbose dl_checksum = download(filename) puts " [i] Downloaded File Checksum: #{dl_checksum}" if verbose puts " [i] Database File Checksum : #{db_checksum}" if verbose diff --git a/lib/wpscan/web_site.rb b/lib/wpscan/web_site.rb index 002f324b..a377e5e8 100644 --- a/lib/wpscan/web_site.rb +++ b/lib/wpscan/web_site.rb @@ -1,11 +1,17 @@ # encoding: UTF-8 -require 'web_site/robots_txt' +require 'web_site/humans_txt' require 'web_site/interesting_headers' +require 'web_site/robots_txt' +require 'web_site/security_txt' +require 'web_site/sitemap' class WebSite - include WebSite::RobotsTxt + include WebSite::HumansTxt include WebSite::InterestingHeaders + include WebSite::RobotsTxt + include WebSite::SecurityTxt + include WebSite::Sitemap attr_reader :uri @@ -121,13 +127,6 @@ class WebSite @error_404_hash end - # Will try to find the rss url in the homepage - # Only the first one found is returned - def rss_url - homepage_body = Browser.get(@uri.to_s).body - homepage_body[%r{}, 1] - end - # Only the first 700 bytes are checked to avoid the download # of the whole file which can be very huge (like 2 Go) # diff --git a/lib/wpscan/web_site/humans_txt.rb b/lib/wpscan/web_site/humans_txt.rb new file mode 100644 index 00000000..e9eceaad --- /dev/null +++ b/lib/wpscan/web_site/humans_txt.rb @@ -0,0 +1,13 @@ +# encoding: UTF-8 + +class WebSite + module HumansTxt + + # Gets the humans.txt URL + # @return [ String ] + def humans_url + @uri.clone.merge('humans.txt').to_s + end + + end +end diff --git a/lib/wpscan/web_site/robots_txt.rb b/lib/wpscan/web_site/robots_txt.rb index 2e928152..b9f6589f 100644 --- a/lib/wpscan/web_site/robots_txt.rb +++ b/lib/wpscan/web_site/robots_txt.rb @@ -18,49 +18,53 @@ class WebSite # Parse robots.txt # @return [ Array ] URLs generated from robots.txt def parse_robots_txt - return unless has_robots? - return_object = [] + + # Make request response = Browser.get(robots_url.to_s) body = response.body + # Get all allow and disallow urls entries = body.scan(/^(?:dis)?allow:\s*(.*)$/i) + + # Did we get something? if entries - entries.flatten! - entries.compact.sort! - entries.uniq! + # Remove any rubbish + entries = clean_uri(entries) + + # Sort + entries.sort! + + # Wordpress URL wordpress_path = @uri.path + + # Each "boring" value as defined below, remove RobotsTxt.known_dirs.each do |d| entries.delete(d) - # also delete when wordpress is installed in subdir + # Also delete when wordpress is installed in subdir dir_with_subdir = "#{wordpress_path}/#{d}".gsub(/\/+/, '/') entries.delete(dir_with_subdir) end - entries.each do |d| - begin - temp = @uri.clone - temp.path = d.strip - rescue URI::Error - temp = d.strip - end - return_object << temp.to_s - end + # Convert to full URIs + return_object = full_uri(entries) end - return_object + return return_object end protected + # Useful ~ "function do_robots()" -> https://github.com/WordPress/WordPress/blob/master/wp-includes/functions.php + # # @return [ Array ] def self.known_dirs %w{ / /wp-admin/ + /wp-admin/admin-ajax.php /wp-includes/ /wp-content/ } end - end end diff --git a/lib/wpscan/web_site/security_txt.rb b/lib/wpscan/web_site/security_txt.rb new file mode 100644 index 00000000..c8f8687e --- /dev/null +++ b/lib/wpscan/web_site/security_txt.rb @@ -0,0 +1,13 @@ +# encoding: UTF-8 + +class WebSite + module SecurityTxt + + # Gets the security.txt URL + # @return [ String ] + def security_url + @uri.clone.merge('.well-known/security.txt').to_s + end + + end +end diff --git a/lib/wpscan/web_site/sitemap.rb b/lib/wpscan/web_site/sitemap.rb new file mode 100644 index 00000000..46140a03 --- /dev/null +++ b/lib/wpscan/web_site/sitemap.rb @@ -0,0 +1,53 @@ +# encoding: UTF-8 + +class WebSite + module Sitemap + + # Checks if a sitemap.txt file exists + # @return [ Boolean ] + def has_sitemap? + # Make the request + response = Browser.get(sitemap_url) + + # Make sure its HTTP 200 + return false unless response.code == 200 + + # Is there a sitemap value? + result = response.body.scan(/^sitemap\s*:\s*(.*)$/i) + return true if result[0] + return false + end + + # Get the robots.txt URL + # @return [ String ] + def sitemap_url + @uri.clone.merge('robots.txt').to_s + end + + # Parse robots.txt + # @return [ Array ] URLs generated from robots.txt + def parse_sitemap + return_object = [] + + # Make request + response = Browser.get(sitemap_url.to_s) + + # Get all allow and disallow urls + entries = response.body.scan(/^sitemap\s*:\s*(.*)$/i) + + # Did we get something? + if entries + # Remove any rubbish + entries = clean_uri(entries) + + # Sort + entries.sort! + + # Convert to full URIs + return_object = full_uri(entries) + end + return return_object + end + + end +end diff --git a/lib/wpscan/wp_target.rb b/lib/wpscan/wp_target.rb index 9fa0325e..f9f7c688 100644 --- a/lib/wpscan/wp_target.rb +++ b/lib/wpscan/wp_target.rb @@ -1,22 +1,26 @@ # encoding: UTF-8 require 'web_site' -require 'wp_target/wp_readme' -require 'wp_target/wp_registrable' +require 'wp_target/wp_api' require 'wp_target/wp_config_backup' -require 'wp_target/wp_must_use_plugins' -require 'wp_target/wp_login_protection' require 'wp_target/wp_custom_directories' require 'wp_target/wp_full_path_disclosure' +require 'wp_target/wp_login_protection' +require 'wp_target/wp_must_use_plugins' +require 'wp_target/wp_readme' +require 'wp_target/wp_registrable' +require 'wp_target/wp_rss' class WpTarget < WebSite - include WpTarget::WpReadme - include WpTarget::WpRegistrable + include WpTarget::WpAPI include WpTarget::WpConfigBackup - include WpTarget::WpMustUsePlugins - include WpTarget::WpLoginProtection include WpTarget::WpCustomDirectories include WpTarget::WpFullPathDisclosure + include WpTarget::WpLoginProtection + include WpTarget::WpMustUsePlugins + include WpTarget::WpReadme + include WpTarget::WpRegistrable + include WpTarget::WpRSS attr_reader :verbose diff --git a/lib/wpscan/wp_target/wp_api.rb b/lib/wpscan/wp_target/wp_api.rb new file mode 100644 index 00000000..2a52c4df --- /dev/null +++ b/lib/wpscan/wp_target/wp_api.rb @@ -0,0 +1,86 @@ +# encoding: UTF-8 + +class WpTarget < WebSite + module WpAPI + + # Checks to see if the REST API is enabled + # + # This by default in a WordPress installation since 4.5+ + # @return [ Boolean ] + def has_api?(url) + # Make the request + response = Browser.get(url) + + # Able to view the output? + if valid_json?(response.body) + # Read in JSON + data = JSON.parse(response.body) + + # If there is nothing there, return false + if data.empty? + return false + # WAF/API disabled response + elsif data.include?('message') and data['message'] =~ /Only authenticated users can access the REST API/ + return false + # Success! + elsif response.code == 200 + return true + end + end + + # Something went wrong + return false + end + + # @return [ String ] The API/JSON URL + def json_url + @uri.merge('/wp-json/').to_s + end + + # @return [ String ] The API/JSON URL to show users + def json_users_url + @uri.merge('/wp-json/wp/v2/users').to_s + end + + # @return [ String ] The API/JSON URL to show users + def json_get_users(url) + # Variables + users = [] + + # Make the request + response = Browser.get(url) + + # If not HTTP 200, return false + return false unless response.code == 200 + + # Able to view the output? + return false unless valid_json?(response.body) + + # Read in JSON + data = JSON.parse(response.body) + + # If there is nothing there, return false + return false if data.empty? + + # Add to array + data.each do |child| + row = [ child['id'], child['name'], child['link'] ] + users << row + end + + # Sort and uniq + users = users.sort.uniq + + if users and users.size >= 1 + # Feedback + grammar = grammar_s(users.size) + puts warning("#{users.size} user#{grammar} exposed via API: #{json_users_url}") + + # Print results + table = Terminal::Table.new(headings: ['ID', 'Name', 'URL'], + rows: users) + puts table + end + end + end +end diff --git a/lib/wpscan/wp_target/wp_rss.rb b/lib/wpscan/wp_target/wp_rss.rb new file mode 100644 index 00000000..1fc34839 --- /dev/null +++ b/lib/wpscan/wp_target/wp_rss.rb @@ -0,0 +1,68 @@ +# encoding: UTF-8 + +class WpTarget < WebSite + module WpRSS + + # Checks to see if there is an rss feed + # Will try to find the rss url in the homepage + # Only the first one found is returned + # + # This file comes by default in a WordPress installation + # + # @return [ Boolean ] + def rss_url + homepage_body = Browser.get(@uri.to_s).body + # Format: + homepage_body[%r{}i, 1] + end + + + # Gets all the authors from the RSS feed + # + # @return [ string ] + def rss_authors(url) + # Variables + users = [] + + # Make the request + response = Browser.get(url, followlocation: true) + + # Valid repose to view? HTTP 200? + return false unless response.code == 200 + + # Get output + data = response.body + + # If there is nothing there, return false + return false if data.empty? + + # Read in RSS/XML + xml = Nokogiri::XML(data) + + begin + # Look for item + xml.xpath('//item/dc:creator').each do |node| + #Format: + users << [%r{.*}i.match(node).to_s] + end + rescue + puts critical("Missing Author field. Maybe non-standard WordPress RSS feed?") + return false + end + + # Sort and uniq + users = users.sort_by { |user| user.to_s.downcase }.uniq + + if users and users.size >= 1 + # Feedback + grammar = grammar_s(users.size) + puts warning("Detected #{users.size} user#{grammar} from RSS feed:") + + # Print results + table = Terminal::Table.new(headings: ['Name'], + rows: users) + puts table + end + end + end +end diff --git a/lib/wpscan/wpscan_helper.rb b/lib/wpscan/wpscan_helper.rb index d007f948..f2fc5470 100644 --- a/lib/wpscan/wpscan_helper.rb +++ b/lib/wpscan/wpscan_helper.rb @@ -49,7 +49,7 @@ def usage puts '-Use custom plugins directory ...' puts "ruby #{script_name} -u www.example.com --wp-plugins-dir wp-content/custom-plugins" puts - puts '-Update the DB ...' + puts '-Update the Database ...' puts "ruby #{script_name} --update" puts puts '-Debug output ...' @@ -120,6 +120,58 @@ def help puts end + +def clean_uri(entries) + # Extract elements + entries.flatten! + # Remove any leading/trailing spaces + entries.collect{|x| x.strip || x } + # End Of Line issues + entries.collect{|x| x.chomp! || x } + # Remove nil's + entries.compact + # Unique values only + entries.uniq! + + return entries +end + +# Return the full URL +def full_uri(entries) + return_object = [] + # Each value now, try and make it a full URL + entries.each do |d| + begin + temp = @uri.clone + temp.path = d.strip + rescue URI::Error + temp = d.strip + end + return_object << temp.to_s + end + + return return_object +end + +# Parse humans.txt +# @return [ Array ] URLs generated from humans.txt +def parse_txt(url) + return_object = [] + response = Browser.get(url.to_s) + body = response.body + + # Get all non-comments + entries = body.split(/\n/) + + # Did we get something? + if entries + # Remove any rubbish + entries = clean_uri(entries) + end + return return_object +end + + # Hook to check if the target if down during the scan # And have the number of requests performed to display at the end of the scan # The target is considered down after 30 requests with status = 0 @@ -138,3 +190,4 @@ Typhoeus.on_complete do |response| sleep(Browser.instance.throttle) end + diff --git a/spec/lib/wpscan/web_site_spec.rb b/spec/lib/wpscan/web_site_spec.rb index 4eeda2eb..56b150b8 100644 --- a/spec/lib/wpscan/web_site_spec.rb +++ b/spec/lib/wpscan/web_site_spec.rb @@ -207,18 +207,6 @@ describe 'WebSite' do end end - describe '#rss_url' do - it 'returns nil if the url is not found' do - stub_request(:get, web_site.url).to_return(body: 'No RSS link in this body !') - expect(web_site.rss_url).to be_nil - end - - it "returns 'http://lamp-wp/wordpress-3.5/?feed=rss2'" do - stub_request_to_fixture(url: web_site.url, fixture: fixtures_dir + '/rss_url/wordpress-3.5.htm') - expect(web_site.rss_url).to be === 'http://lamp-wp/wordpress-3.5/?feed=rss2' - end - end - describe '::has_log?' do let(:log_url) { web_site.uri.merge('log.txt').to_s } let(:pattern) { %r{PHP Fatal error} } diff --git a/spec/shared_examples/web_site/humans_txt.rb b/spec/shared_examples/web_site/humans_txt.rb new file mode 100644 index 00000000..b54d1ed5 --- /dev/null +++ b/spec/shared_examples/web_site/humans_txt.rb @@ -0,0 +1,108 @@ +# encoding: UTF-8 + +shared_examples 'WebSite::HumansTxt' do + let(:known_dirs) { WebSite::HumansTxt.known_dirs } + + describe '#humans_url' do + it 'returns the correct url' do + expect(web_site.humans_url).to eql 'http://example.localhost/humans.txt' + end + end + + describe '#has_humans?' do + it 'returns true' do + stub_request(:get, web_site.humans_url).to_return(status: 200) + expect(web_site.has_humans?).to be_truthy + end + + it 'returns false' do + stub_request(:get, web_site.humans_url).to_return(status: 404) + expect(web_site.has_humans?).to be_falsey + end + end + + describe '#parse_humans_txt' do + + context 'installed in root' do + after :each do + stub_request_to_fixture(url: web_site.humans_url, fixture: @fixture) + humans = web_site.parse_humans_txt + expect(humans).to match_array @expected + end + + it 'returns an empty Array (empty humans.txt)' do + @fixture = fixtures_dir + '/humans_txt/empty_humans.txt' + @expected = [] + end + + it 'returns an empty Array (invalid humans.txt)' do + @fixture = fixtures_dir + '/humans_txt/invalid_humans.txt' + @expected = [] + end + + it 'returns some urls and some strings' do + @fixture = fixtures_dir + '/humans_txt/invalid_humans_2.txt' + @expected = %w( + /ÖÜ()=? + http://10.0.0.0/wp-includes/ + http://example.localhost/asdf/ + wooooza + ) + end + + it 'returns an Array of urls (valid humans.txt)' do + @fixture = fixtures_dir + '/humans_txt/humans.txt' + @expected = %w( + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/wp-admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + end + + it 'removes duplicate entries from humans.txt test 1' do + @fixture = fixtures_dir + '/humans_txt/humans_duplicate_1.txt' + @expected = %w( + http://example.localhost/wordpress/ + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/wp-admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + end + + it 'removes duplicate entries from humans.txt test 2' do + @fixture = fixtures_dir + '/humans_txt/humans_duplicate_2.txt' + @expected = nil + end + end + + context 'installed in sub directory' do + it 'returns an Array of urls (valid humans.txt, WP installed in subdir)' do + web_site_sub = WebSite.new('http://example.localhost/wordpress/') + fixture = fixtures_dir + '/humans_txt/humans.txt' + expected = %w( + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + stub_request_to_fixture(url: web_site_sub.humans_url, fixture: fixture) + humans = web_site_sub.parse_humans_txt + expect(humans).to match_array expected + end + end + end + + describe '#known_dirs' do + it 'does not contain duplicates' do + expect(known_dirs.flatten.uniq.length).to eq known_dirs.length + end + end + +end diff --git a/spec/shared_examples/web_site/security_txt.rb b/spec/shared_examples/web_site/security_txt.rb new file mode 100644 index 00000000..3bca86dd --- /dev/null +++ b/spec/shared_examples/web_site/security_txt.rb @@ -0,0 +1,108 @@ +# encoding: UTF-8 + +shared_examples 'WebSite::SecurityTxt' do + let(:known_dirs) { WebSite::SecurityTxt.known_dirs } + + describe '#security_url' do + it 'returns the correct url' do + expect(web_site.security_url).to eql 'http://example.localhost/security.txt' + end + end + + describe '#has_security?' do + it 'returns true' do + stub_request(:get, web_site.security_url).to_return(status: 200) + expect(web_site.has_security?).to be_truthy + end + + it 'returns false' do + stub_request(:get, web_site.security_url).to_return(status: 404) + expect(web_site.has_security?).to be_falsey + end + end + + describe '#parse_security_txt' do + + context 'installed in root' do + after :each do + stub_request_to_fixture(url: web_site.security_url, fixture: @fixture) + security = web_site.parse_security_txt + expect(security).to match_array @expected + end + + it 'returns an empty Array (empty security.txt)' do + @fixture = fixtures_dir + '/security_txt/empty_security.txt' + @expected = [] + end + + it 'returns an empty Array (invalid security.txt)' do + @fixture = fixtures_dir + '/security_txt/invalid_security.txt' + @expected = [] + end + + it 'returns some urls and some strings' do + @fixture = fixtures_dir + '/security_txt/invalid_security_2.txt' + @expected = %w( + /ÖÜ()=? + http://10.0.0.0/wp-includes/ + http://example.localhost/asdf/ + wooooza + ) + end + + it 'returns an Array of urls (valid security.txt)' do + @fixture = fixtures_dir + '/security_txt/security.txt' + @expected = %w( + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/wp-admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + end + + it 'removes duplicate entries from security.txt test 1' do + @fixture = fixtures_dir + '/security_txt/security_duplicate_1.txt' + @expected = %w( + http://example.localhost/wordpress/ + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/wp-admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + end + + it 'removes duplicate entries from security.txt test 2' do + @fixture = fixtures_dir + '/security_txt/security_duplicate_2.txt' + @expected = nil + end + end + + context 'installed in sub directory' do + it 'returns an Array of urls (valid security.txt, WP installed in subdir)' do + web_site_sub = WebSite.new('http://example.localhost/wordpress/') + fixture = fixtures_dir + '/security_txt/security.txt' + expected = %w( + http://example.localhost/wordpress/admin/ + http://example.localhost/wordpress/secret/ + http://example.localhost/Wordpress/wp-admin/ + http://example.localhost/wp-admin/tralling-space/ + http://example.localhost/asdf/ + ) + stub_request_to_fixture(url: web_site_sub.security_url, fixture: fixture) + security = web_site_sub.parse_security_txt + expect(security).to match_array expected + end + end + end + + describe '#known_dirs' do + it 'does not contain duplicates' do + expect(known_dirs.flatten.uniq.length).to eq known_dirs.length + end + end + +end diff --git a/wpscan.rb b/wpscan.rb index 28ffc091..b10aeafb 100755 --- a/wpscan.rb +++ b/wpscan.rb @@ -10,6 +10,7 @@ require File.join(__dir__, 'lib', 'wpscan', 'wpscan_helper') def main begin wpscan_options = WpscanOptions.load_from_arguments + date = last_update $log = wpscan_options.log @@ -27,7 +28,7 @@ def main # check if file exists and has a size greater zero if File.exist?($log) && File.size?($log) puts notice("The supplied log file #{$log} already exists. If you continue the new output will be appended.") - print '[?] Do you want to continue? [Y]es [N]o, default: [N]' + print '[?] Do you want to continue? [Y]es [N]o, default: [N] >' if Readline.readline !~ /^y/i # unset logging so puts will try to log to the file $log = nil @@ -54,6 +55,8 @@ def main unless wpscan_options.has_options? # first parameter only url? if ARGV.length == 1 + puts + puts notice("Please use '-u #{ARGV[0]}' next time") wpscan_options.url = ARGV[0] else usage() @@ -72,8 +75,7 @@ def main if wpscan_options.version puts "Current version: #{WPSCAN_VERSION}" - date = last_update - puts "Last DB update: #{date.strftime('%Y-%m-%d')}" unless date.nil? + puts "Last database update: #{date.strftime('%Y-%m-%d')}" unless date.nil? exit(0) end @@ -83,28 +85,44 @@ def main wpscan_options.to_h.merge(max_threads: wpscan_options.threads) ) - # check if db file needs upgrade and we are not running in batch mode - # also no need to check if the user supplied the --update switch - if update_required? && !wpscan_options.batch && !wpscan_options.update - puts notice('It seems like you have not updated the database for some time.') - print '[?] Do you want to update now? [Y]es [N]o [A]bort, default: [N]' - if (input = Readline.readline) =~ /^y/i + # Check if database needs upgrade (if its older than 5 days) and we are not running in --batch mode + # Also no need to check if the user supplied the --update switch + if update_required? and not wpscan_options.batch and not wpscan_options.update + # Banner + puts + puts notice('It seems like you have not updated the database for some time') + puts notice("Last database update: #{date.strftime('%Y-%m-%d')}") unless date.nil? + + # User prompt + print '[?] Do you want to update now? [Y]es [N]o [A]bort update, default: [N] > ' + if (input = Readline.readline) =~ /^a/i + puts 'Update aborted' + elsif input =~ /^y/i wpscan_options.update = true - elsif input =~ /^a/i - puts 'Scan aborted' - exit(1) - else - if missing_db_file? - puts critical('You can not run a scan without any databases. Extract the data.zip file.') + end + + # Is there a database to go on with? + if missing_db_files? and not wpscan_options.update + # Check for data.zip + if has_db_zip? + puts notice('Extracting the Database ...') + # Extract data.zip + extract_db_zip + puts notice('Extraction completed') + # Missing, so can't go on! + else + puts critical('You can not run a scan without any databases') exit(1) end end end + # Should we update? if wpscan_options.update puts notice('Updating the Database ...') DbUpdater.new(DATA_DIR).update(wpscan_options.verbose) - puts notice('Update completed.') + puts notice('Update completed') + # Exit program if only option --update is used exit(0) unless wpscan_options.url end @@ -120,12 +138,18 @@ def main end if wp_target.ssl_error? - raise "The target site returned an SSL/TLS error. You can try again using the --disable-tls-checks option.\nError: #{wp_target.get_root_path_return_code}\nSee here for a detailed explanation of the error: http://www.rubydoc.info/github/typhoeus/ethon/Ethon/Easy:return_code" + raise "The target site returned an SSL/TLS error. You can try again using --disable-tls-checks\nError: #{wp_target.get_root_path_return_code}\nSee here for a detailed explanation of the error: http://www.rubydoc.info/github/typhoeus/ethon/Ethon/Easy:return_code" end # Remote website up? unless wp_target.online? - raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking wpscan so you can try the --random-agent parameter." + if wpscan_options.user_agent + puts info("User-Agent: #{wpscan_options.user_agent}") + raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking the user-agent?" + else + raise "The WordPress URL supplied '#{wp_target.uri}' seems to be down. Maybe the site is blocking the wpscan user-agent, so you can try --random-agent" + end + end if wpscan_options.proxy @@ -145,7 +169,7 @@ def main puts "Following redirection #{redirection}" else puts notice("The remote host tried to redirect to: #{redirection}") - print '[?] Do you want follow the redirection ? [Y]es [N]o [A]bort, default: [N]' + print '[?] Do you want follow the redirection ? [Y]es [N]o [A]bort, default: [N] >' end if wpscan_options.follow_redirection || !wpscan_options.batch if wpscan_options.follow_redirection || (input = Readline.readline) =~ /^y/i @@ -174,7 +198,7 @@ def main # Remote website is wordpress? unless wpscan_options.force unless wp_target.wordpress? - raise 'The remote website is up, but does not seem to be running WordPress.' + raise 'The remote website is up, but does not seem to be running WordPress. If you are sure, use --force' end end @@ -196,35 +220,8 @@ def main start_memory = get_memory_usage unless windows? puts info("URL: #{wp_target.url}") puts info("Started: #{start_time.asctime}") - puts - - if wp_target.has_robots? - puts info("robots.txt available under: '#{wp_target.robots_url}'") - - wp_target.parse_robots_txt.each do |dir| - puts info("Interesting entry from robots.txt: #{dir}") - end - end - - if wp_target.has_full_path_disclosure? - puts warning("Full Path Disclosure (FPD) in '#{wp_target.full_path_disclosure_url}': #{wp_target.full_path_disclosure_data}") - end - - if wp_target.has_debug_log? - puts critical("Debug log file found: #{wp_target.debug_log_url}") - end - - wp_target.config_backup.each do |file_url| - puts critical("A wp-config.php backup file has been found in: '#{file_url}'") - end - - if wp_target.search_replace_db_2_exists? - puts critical("searchreplacedb2.php has been found in: '#{wp_target.search_replace_db_2_url}'") - end - - if wp_target.emergency_exists? - puts critical("emergency.php has been found in: '#{wp_target.emergency_url}'") - end + puts info("User-Agent: #{wpscan_options.user_agent}") if wpscan_options.verbose and wpscan_options.user_agent + spacer() wp_target.interesting_headers.each do |header| output = info('Interesting header: ') @@ -237,29 +234,126 @@ def main puts output + "#{header[0]}: #{header[1]}" end end + spacer() + + if wp_target.has_robots? + code = get_http_status(wp_target.robots_url) + puts info("robots.txt available under: #{wp_target.robots_url} [HTTP #{code}]") + + wp_target.parse_robots_txt.each do |dir| + code = get_http_status(dir) + puts info("Interesting entry from robots.txt: #{dir} [HTTP #{code}]") + end + spacer() + end + + if wp_target.has_sitemap? + code = get_http_status(wp_target.sitemap_url) + puts info("Sitemap found: #{wp_target.sitemap_url} [HTTP #{code}]") + + wp_target.parse_sitemap.each do |dir| + code = get_http_status(dir) + puts info("Sitemap entry: #{dir} [HTTP #{code}]") + end + spacer() + end + + code = get_http_status(wp_target.humans_url) + if code == 200 + puts info("humans.txt available under: #{wp_target.humans_url} [HTTP #{code}]") + + parse_txt(wp_target.humans_url).each do |dir| + puts info("Entry from humans.txt: #{dir}") + end + spacer() + end + + code = get_http_status(wp_target.security_url) + if code == 200 + puts info("security.txt available under: #{wp_target.security_url} [HTTP #{code}]") + + parse_txt(wp_target.security_url).each do |dir| + puts info("Entry from security.txt: #{dir}") + end + spacer() + end + + if wp_target.has_debug_log? + puts critical("Debug log file found: #{wp_target.debug_log_url}") + spacer() + end + + wp_target.config_backup.each do |file_url| + puts critical("A wp-config.php backup file has been found in: #{file_url}") + spacer() + end + + if wp_target.search_replace_db_2_exists? + puts critical("searchreplacedb2.php has been found in: #{wp_target.search_replace_db_2_url}") + spacer() + end + + if wp_target.emergency_exists? + puts critical("emergency.php has been found in: #{wp_target.emergency_url}") + spacer() + end if wp_target.multisite? puts info('This site seems to be a multisite (http://codex.wordpress.org/Glossary#Multisite)') + spacer() end if wp_target.has_must_use_plugins? puts info("This site has 'Must Use Plugins' (http://codex.wordpress.org/Must_Use_Plugins)") - end - - if wp_target.registration_enabled? - puts warning("Registration is enabled: #{wp_target.registration_url}") + spacer() end if wp_target.has_xml_rpc? - puts info("XML-RPC Interface available under: #{wp_target.xml_rpc_url}") + code = get_http_status(wp_target.xml_rpc_url) + puts info("XML-RPC Interface available under: #{wp_target.xml_rpc_url} [HTTP #{code}]") + spacer() + end + + # Test to see if MAIN API URL gives anything back + if wp_target.has_api?(wp_target.json_url) + code = get_http_status(wp_target.json_url) + puts info("API exposed: #{wp_target.json_url} [HTTP #{code}]") + + # Test to see if USER API URL gives anything back + if wp_target.has_api?(wp_target.json_users_url) + # Print users from JSON + wp_target.json_get_users(wp_target.json_users_url) + end + spacer() + end + + # Get RSS + rss = wp_target.rss_url + if rss + code = get_http_status(rss) + + # Feedback + puts info("Found an RSS Feed: #{rss} [HTTP #{code}]") + + # Print users from RSS feed + wp_target.rss_authors(rss) + + spacer() + end + + if wp_target.has_full_path_disclosure? + puts warning("Full Path Disclosure (FPD) in '#{wp_target.full_path_disclosure_url}': #{wp_target.full_path_disclosure_data}") + spacer() end if wp_target.upload_directory_listing_enabled? puts warning("Upload directory has directory listing enabled: #{wp_target.upload_dir_url}") + spacer() end if wp_target.include_directory_listing_enabled? puts warning("Includes directory has directory listing enabled: #{wp_target.includes_dir_url}") + spacer() end enum_options = { @@ -267,6 +361,7 @@ def main exclude_content: wpscan_options.exclude_content_based } + puts info('Enumerating WordPress version ...') if (wp_version = wp_target.version(WP_VERSIONS_FILE)) if wp_target.has_readme? && VersionCompare::lesser?(wp_version.identifier, '4.7') puts warning("The WordPress '#{wp_target.readme_url}' file exists exposing a version number") @@ -277,6 +372,7 @@ def main puts puts notice('WordPress version can not be detected') end + spacer() if wp_theme = wp_target.theme puts @@ -295,7 +391,7 @@ def main parent.output(wpscan_options.verbose) wp_theme = parent end - + spacer() end if wpscan_options.enumerate_plugins == nil and wpscan_options.enumerate_only_vulnerable_plugins == nil @@ -304,15 +400,13 @@ def main wp_plugins = WpPlugins.passive_detection(wp_target) if !wp_plugins.empty? - if wp_plugins.size == 1 - puts " | #{wp_plugins.size} plugin found:" - else - puts " | #{wp_plugins.size} plugins found:" - end + grammar = grammar_s(wp_plugins.size) + puts " | #{wp_plugins.size} plugin#{grammar} found:" wp_plugins.output(wpscan_options.verbose) else - puts info('No plugins found') + puts info('No plugins found passively') end + spacer() end # Enumerate the installed plugins @@ -343,12 +437,14 @@ def main puts if !wp_plugins.empty? - puts info("We found #{wp_plugins.size} plugins:") + grammar = grammar_s(wp_plugins.size) + puts info("We found #{wp_plugins.size} plugin#{grammar}:") wp_plugins.output(wpscan_options.verbose) else puts info('No plugins found') end + spacer() end # Enumerate installed themes @@ -378,12 +474,14 @@ def main ) puts if !wp_themes.empty? - puts info("We found #{wp_themes.size} themes:") + grammar = grammar_s(wp_themes.size) + puts info("We found #{wp_themes.size} theme#{grammar}:") wp_themes.output(wpscan_options.verbose) else puts info('No themes found') end + spacer() end if wpscan_options.enumerate_timthumbs @@ -393,18 +491,20 @@ def main wp_timthumbs = WpTimthumbs.aggressive_detection(wp_target, enum_options.merge( - file: DATA_DIR + '/timthumbs.txt', + file: TIMTHUMBS_FILE, theme_name: wp_theme ? wp_theme.name : nil ) ) puts if !wp_timthumbs.empty? - puts info("We found #{wp_timthumbs.size} timthumb file/s:") + grammar = grammar_s(wp_timthumbs.size) + puts info("We found #{wp_timthumbs.size} timthumb file#{grammar}:") wp_timthumbs.output(wpscan_options.verbose) else puts info('No timthumb files found') end + spacer() end # If we haven't been supplied a username/usernames list, enumerate them... @@ -432,7 +532,8 @@ def main exit(1) end else - puts info("Identified the following #{wp_users.size} user/s:") + grammar = grammar_s(wp_users.size) + puts info("We identified the following #{wp_users.size} user#{grammar}:") wp_users.output(margin_left: ' ' * 4) if wp_users[0].login == "admin" puts warning("Default first WordPress username 'admin' is still used") @@ -442,10 +543,12 @@ def main else wp_users = WpUsers.new + # Username file? if wpscan_options.usernames File.open(wpscan_options.usernames).each do |username| wp_users << WpUser.new(wp_target.uri, login: username.chomp) end + # Single username? else wp_users << WpUser.new(wp_target.uri, login: wpscan_options.username) end @@ -455,7 +558,6 @@ def main bruteforce = true if wpscan_options.wordlist if wp_target.has_login_protection? - protection_plugin = wp_target.login_protection_plugin() puts @@ -481,6 +583,7 @@ def main else puts critical('Brute forcing aborted') end + spacer() end stop_time = Time.now @@ -489,9 +592,9 @@ def main puts puts info("Finished: #{stop_time.asctime}") - puts info("Requests Done: #{@total_requests_done}") - puts info("Memory used: #{used_memory.bytes_to_human}") unless windows? puts info("Elapsed time: #{Time.at(elapsed).utc.strftime('%H:%M:%S')}") + puts info("Requests made: #{@total_requests_done}") + puts info("Memory used: #{used_memory.bytes_to_human}") unless windows? # do nothing on interrupt rescue Interrupt