mirror of
https://github.com/urbanadventurer/WhatWeb
synced 2026-06-21 14:12:19 +00:00
454 lines
14 KiB
Ruby
454 lines
14 KiB
Ruby
# Copyright 2009 to 2025 Andrew Horton and Brendan Coles
|
|
#
|
|
# This file is part of WhatWeb.
|
|
#
|
|
# WhatWeb is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 2 of the License, or
|
|
# at your option) any later version.
|
|
#
|
|
# WhatWeb is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with WhatWeb. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
# backwards compatible convenience method for plugins to use
|
|
def open_target(url)
|
|
newtarget = Target.new(url)
|
|
begin
|
|
newtarget.open
|
|
rescue StandardError => err
|
|
error("ERROR Opening: #{newtarget} - #{err}")
|
|
end
|
|
# it doesn't matter if the plugin only pulls 5 instead of 6 variables
|
|
[newtarget.status, newtarget.uri, newtarget.ip, newtarget.body, newtarget.headers, newtarget.raw_headers]
|
|
end
|
|
|
|
def decode_html_entities(s)
|
|
html_entities = { '"' => '"', ''' => "'", '&' => '&', '<' => '<', '>' => '>' }
|
|
s.gsub( /#{html_entities.keys.join("|")}/, html_entities)
|
|
end
|
|
|
|
### matching
|
|
|
|
# fuzzy matching ftw
|
|
def make_tag_pattern(b)
|
|
# remove stuff between script and /script
|
|
# don't bother with !--, --> or noscript and /noscript
|
|
inscript = false
|
|
|
|
b.scan(/<([^\s>]*)/).flatten.map do |x|
|
|
x.downcase!
|
|
r = nil
|
|
r = x if inscript == false
|
|
inscript = true if x == 'script'
|
|
(inscript = false; r = x) if x == '/script'
|
|
r
|
|
end.compact.join(',')
|
|
end
|
|
|
|
# some plugins want a random string in URLs
|
|
def randstr
|
|
rand(36**8).to_s(36)
|
|
end
|
|
|
|
def match_ghdb(ghdb, body, _meta, _status, base_uri)
|
|
# this could be made faster by creating code to eval once for each plugin
|
|
|
|
debug("match_ghdb: #{ghdb}")
|
|
|
|
# take a GHDB string and turn it into code to be evaluated
|
|
matches = [] # fill with true or false. succeeds if all true
|
|
s = ghdb
|
|
|
|
# does it contain intitle?
|
|
if s =~ /intitle:/i
|
|
# extract either the next word or the following words enclosed in "s, it can't possibly be both
|
|
intitle = (s.scan(/intitle:"([^"]*)"/i) + s.scan(/intitle:([^"]\w+)/i)).to_s
|
|
matches << ((body =~ /<title>[^<]*#{Regexp.escape(intitle)}[^<]*<\/title>/i).nil? ? false : true)
|
|
# strip out the intitle: part
|
|
s = s.gsub(/intitle:"([^"]*)"/i, '').gsub(/intitle:([^"]\w+)/i, '')
|
|
end
|
|
|
|
if s =~ /filetype:/i
|
|
filetype = (s.scan(/filetype:"([^"]*)"/i) + s.scan(/filetype:([^"]\w+)/i)).to_s
|
|
# lame method: check if the URL ends in the filetype
|
|
unless base_uri.nil?
|
|
unless base_uri.path.split('?')[0].nil?
|
|
matches << ((base_uri.path.split('?')[0] =~ /#{Regexp.escape(filetype)}$/i).nil? ? false : true)
|
|
end
|
|
end
|
|
s = s.gsub(/filetype:"([^"]*)"/i, '').gsub(/filetype:([^"]\w+)/i, '')
|
|
end
|
|
|
|
if s =~ /inurl:/i
|
|
inurl = (s.scan(/inurl:"([^"]*)"/i) + s.scan(/inurl:([^"]\w+)/i)).flatten
|
|
# can occur multiple times.
|
|
inurl.each { |x| matches << ((base_uri.to_s =~ /#{Regexp.escape(x)}/i).nil? ? false : true) }
|
|
# strip out the inurl: part
|
|
s = s.gsub(/inurl:"([^"]*)"/i, '').gsub(/inurl:([^"]\w+)/i, '')
|
|
end
|
|
|
|
# split the remaining words except those enclosed in quotes, remove the quotes and sort them
|
|
|
|
remaining_words = s.scan(/([^ "]+)|("[^"]+")/i).flatten.compact.each { |w| w.delete!('"') }.sort.uniq
|
|
|
|
debug("Remaining GHDB words: #{remaining_words}")
|
|
|
|
remaining_words.each do |w|
|
|
# does it start with a - ?
|
|
if w[0..0] == '-'
|
|
# reverse true/false if it begins with a -
|
|
matches << ((body =~ /#{Regexp.escape(w[1..-1])}/i).nil? ? true : false)
|
|
else
|
|
w = w[1..-1] if w[0..0] == '+' # if it starts with +, ignore the 1st char
|
|
matches << ((body =~ /#{Regexp.escape(w)}/i).nil? ? false : true)
|
|
end
|
|
end
|
|
|
|
debug("matches: #{matches}")
|
|
|
|
# if all matcbhes are true, then true
|
|
if matches.uniq == [true]
|
|
true
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
#
|
|
# Target
|
|
#
|
|
class Target
|
|
include Helper
|
|
attr_reader :target
|
|
attr_reader :uri, :status, :ip, :body, :headers, :raw_headers, :raw_response
|
|
attr_reader :cookies
|
|
attr_reader :md5sum
|
|
attr_reader :tag_pattern
|
|
attr_reader :is_url, :is_file
|
|
attr_accessor :http_options
|
|
attr_reader :redirect_counter
|
|
|
|
@@meta_refresh_regex = /<meta[\s]+http\-equiv[\s]*=[\s]*['"]?refresh['"]?[^>]+content[\s]*=[^>]*[0-9]+;[\s]*url=['"]?([^"'>]+)['"]?[^>]*>/i
|
|
|
|
def pretty_print
|
|
# "#{target} " + [@uri,@status,@ip,@body,@headers,@raw_headers,@raw_response,@cookies,@md5sum,@tag_pattern,@is_url,@is_file].join(",")
|
|
"URI\n#{'*' * 40}\n#{@uri}\n" \
|
|
"status\n#{'*' * 40}\n#{@status}\n" \
|
|
"ip\n#{'*' * 40}\n#{@ip}\n" \
|
|
"redirects\n#{'*' * 40}\n#{@redirect_counter}\n" \
|
|
"header\n#{'*' * 40}\n#{@headers}\n" \
|
|
"cookies\n#{'*' * 40}\n#{@cookies}\n" \
|
|
"raw_headers\n#{'*' * 40}\n#{@raw_headers}\n" \
|
|
"raw_response\n#{'*' * 40}\n#{@raw_response}\n" \
|
|
"body\n#{'*' * 40}\n#{@body}\n" \
|
|
"md5sum\n#{'*' * 40}\n#{@md5sum}\n" \
|
|
"tag_pattern\n#{'*' * 40}\n#{@tag_pattern}\n" \
|
|
"is_url\n#{'*' * 40}\n#{@is_url}\n" \
|
|
"is_file\n#{'*' * 40}\n#{@is_file}\n"
|
|
end
|
|
|
|
def to_s
|
|
@target
|
|
end
|
|
|
|
def self.meta_refresh_regex
|
|
@@meta_refresh_regex
|
|
end
|
|
|
|
def is_file?
|
|
@is_file
|
|
end
|
|
|
|
def is_url?
|
|
@is_url
|
|
end
|
|
|
|
def initialize(target = nil, redirect_counter = 0)
|
|
@target = target
|
|
@redirect_counter = redirect_counter
|
|
@headers = {}
|
|
@http_options = { method: 'GET' }
|
|
# @status=0
|
|
|
|
@is_url = if @target =~ /^http[s]?:\/\//
|
|
true
|
|
else
|
|
false
|
|
end
|
|
|
|
if File.exist?(@target)
|
|
@is_file = true
|
|
raise "Error: #{@target} is a directory" if File.directory?(@target)
|
|
if File.readable?(@target) == false
|
|
raise "Error: You do not have permission to view #{@target}"
|
|
end
|
|
else
|
|
@is_file = false
|
|
end
|
|
|
|
if is_url?
|
|
@uri = URI.parse(Addressable::URI.encode(@target))
|
|
|
|
# is this taking control away from the user?
|
|
# [400] http://www.alexa.com [200] http://www.alexa.com/
|
|
@uri.path = '/' if @uri.path.empty?
|
|
else
|
|
# @uri=URI.parse("file://"+@target)
|
|
@uri = URI.parse('')
|
|
end
|
|
end
|
|
|
|
def open
|
|
if is_file?
|
|
open_file
|
|
else
|
|
open_url(@http_options)
|
|
end
|
|
|
|
sleep $WAIT if $WAIT
|
|
|
|
## after open
|
|
if @body.nil?
|
|
# Initialize @body variable if the connection is terminated prematurely
|
|
# This is usually caused by HTTP status codes: 101, 102, 204, 205, 305
|
|
@body = ''
|
|
else
|
|
@md5sum = Digest::MD5.hexdigest(@body)
|
|
@tag_pattern = make_tag_pattern(@body)
|
|
if @raw_headers
|
|
@raw_response = @raw_headers + @body
|
|
else
|
|
@raw_response = @body
|
|
@raw_headers = ''
|
|
@cookies = []
|
|
end
|
|
end
|
|
end
|
|
|
|
def open_file
|
|
# target is a file
|
|
@body = File.open(@target).read
|
|
|
|
@body = @body.encode('UTF-16', 'UTF-8', invalid: :replace, replace: '').encode('UTF-8')
|
|
|
|
# target is a http packet file
|
|
if @body =~ /^HTTP\/1\.\d [\d]{3} (.+)\r\n\r\n/m
|
|
# extract http header
|
|
@headers = {}
|
|
pageheaders = body.to_s.split(/\r\n\r\n/).first.to_s.split(/\r\n/)
|
|
@raw_headers = pageheaders.join("\n") + "\r\n\r\n"
|
|
@status = pageheaders.first.scan(/^HTTP\/1\.\d ([\d]{3}) /).flatten.first.to_i
|
|
@cookies = []
|
|
for k in 1...pageheaders.length
|
|
section = pageheaders[k].split(/:/).first.to_s.downcase
|
|
if section =~ /^set-cookie$/i
|
|
@cookies << pageheaders[k].scan(/:[\s]*(.+)$/).flatten.first
|
|
else
|
|
@headers[section] = pageheaders[k].scan(/:[\s]*(.+)$/).flatten.first
|
|
end
|
|
end
|
|
@headers['set-cookie'] = @cookies.join("\n") unless @cookies.nil? || @cookies.empty?
|
|
# extract html source
|
|
if @body =~ /^HTTP\/1\.\d [\d]{3} .+?\r\n\r\n(.+)/m
|
|
@body = @body.scan(/^HTTP\/1\.\d [\d]{3} .+?\r\n\r\n(.+)/m).flatten.first
|
|
end
|
|
end
|
|
rescue StandardError => err
|
|
raise err
|
|
end
|
|
|
|
def open_url(options)
|
|
begin
|
|
# Skip DNS resolution for .onion domains
|
|
if @uri.host.end_with?('.onion')
|
|
# For .onion domains, we'll use the hostname directly
|
|
@ip = @uri.host
|
|
else
|
|
@ip = Resolv.getaddress(@uri.host)
|
|
end
|
|
rescue StandardError => err
|
|
raise err
|
|
end
|
|
|
|
# Merge cookies from jar with user-provided cookies
|
|
if defined?($COOKIE_JAR) && $COOKIE_JAR && (!defined?($NO_COOKIES) || !$NO_COOKIES)
|
|
jar_cookies = $COOKIE_JAR.cookies_for_request(@uri.to_s)
|
|
|
|
if jar_cookies
|
|
existing_cookies = $CUSTOM_HEADERS['Cookie']
|
|
|
|
if existing_cookies && !existing_cookies.empty?
|
|
# Combine: user cookies take precedence
|
|
$CUSTOM_HEADERS['Cookie'] = "#{existing_cookies}; #{jar_cookies}"
|
|
else
|
|
$CUSTOM_HEADERS['Cookie'] = jar_cookies
|
|
end
|
|
end
|
|
end
|
|
|
|
begin
|
|
if $USE_PROXY == true
|
|
http = ExtendedHTTP::Proxy($PROXY_HOST, $PROXY_PORT, $PROXY_USER, $PROXY_PASS).new(@uri.host, @uri.port)
|
|
else
|
|
http = ExtendedHTTP.new(@uri.host, @uri.port)
|
|
end
|
|
|
|
# set timeouts
|
|
http.open_timeout = $HTTP_OPEN_TIMEOUT
|
|
http.read_timeout = $HTTP_READ_TIMEOUT
|
|
|
|
# if it's https://
|
|
# i wont worry about certificates, verfication, etc
|
|
if @uri.class == URI::HTTPS
|
|
http.use_ssl = true
|
|
OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:ciphers] = 'TLSv1:TLSv1.1:TLSv1.2:SSLv3:SSLv2'
|
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
end
|
|
|
|
getthis = @uri.path + (@uri.query.nil? ? '' : '?' + @uri.query)
|
|
req = nil
|
|
|
|
if options[:method] == 'GET'
|
|
req = ExtendedHTTP::Get.new(getthis, $CUSTOM_HEADERS)
|
|
end
|
|
if options[:method] == 'HEAD'
|
|
req = ExtendedHTTP::Head.new(getthis, $CUSTOM_HEADERS)
|
|
end
|
|
if options[:method] == 'POST'
|
|
req = ExtendedHTTP::Post.new(getthis, $CUSTOM_HEADERS)
|
|
req.set_form_data(options[:data])
|
|
end
|
|
|
|
req.basic_auth $BASIC_AUTH_USER, $BASIC_AUTH_PASS if $BASIC_AUTH_USER
|
|
|
|
# Log HTTP request details for debug mode
|
|
request_details = []
|
|
request_details << "\n" + "=" * 60
|
|
request_details << "HTTP REQUEST"
|
|
request_details << "=" * 60
|
|
request_details << "#{options[:method]} #{getthis} HTTP/1.1"
|
|
request_details << "Host: #{@uri.host}#{@uri.port != (@uri.scheme == 'https' ? 443 : 80) ? ":#{@uri.port}" : ''}"
|
|
|
|
# Show custom headers
|
|
$CUSTOM_HEADERS.each do |key, value|
|
|
request_details << "#{key}: #{value}"
|
|
end
|
|
|
|
# Show basic auth header if present
|
|
if $BASIC_AUTH_USER
|
|
request_details << "Authorization: Basic [REDACTED]"
|
|
end
|
|
|
|
# Show POST data if present
|
|
if options[:method] == 'POST' && options[:data]
|
|
request_details << "\n[POST Data]"
|
|
request_details << options[:data].inspect
|
|
end
|
|
request_details << "=" * 60
|
|
|
|
debug(request_details.join("\n"))
|
|
|
|
res = http.request(req)
|
|
@raw_headers = http.raw.join("\n")
|
|
@headers = {}
|
|
|
|
@body = res.body
|
|
|
|
@body = Helper::convert_to_utf8(@body)
|
|
@raw_headers = Helper::convert_to_utf8(@raw_headers)
|
|
|
|
res.each_header do |x, y|
|
|
newx, newy = x.dup, y.dup
|
|
@headers[ Helper::convert_to_utf8(newx) ] = Helper::convert_to_utf8(newy)
|
|
end
|
|
|
|
@headers['set-cookie'] = res.get_fields('set-cookie').join("\n") unless @headers['set-cookie'].nil?
|
|
|
|
# Extract cookies to @cookies array (existing functionality)
|
|
@cookies = res.get_fields('set-cookie') || []
|
|
|
|
# Store cookies from response in the cookie jar
|
|
if defined?($COOKIE_JAR) && $COOKIE_JAR && (!defined?($NO_COOKIES) || !$NO_COOKIES)
|
|
if @headers['set-cookie']
|
|
$COOKIE_JAR.add_cookies(@headers['set-cookie'], @uri.to_s)
|
|
# Display cookie jar contents in debug mode
|
|
$COOKIE_JAR.display_cookies if defined?(debug) && $verbose && $verbose > 2
|
|
end
|
|
end
|
|
|
|
@status = res.code.to_i
|
|
|
|
# Log HTTP response details for debug mode
|
|
response_details = []
|
|
response_details << "\nHTTP RESPONSE"
|
|
response_details << "=" * 60
|
|
response_details << "HTTP/1.1 #{@status} #{res.message}"
|
|
res.each_header do |key, value|
|
|
response_details << "#{key}: #{value}"
|
|
end
|
|
response_details << "=" * 60
|
|
response_details << "Response body length: #{@body ? @body.length : 0} bytes"
|
|
response_details << "=" * 60 + "\n"
|
|
|
|
debug(response_details.join("\n"))
|
|
|
|
debug("#{@uri} [#{status}]")
|
|
|
|
rescue StandardError => err
|
|
raise err
|
|
|
|
end
|
|
end
|
|
|
|
|
|
def get_redirection_target
|
|
newtarget_m, newtarget_h, newtarget = nil
|
|
|
|
if @@meta_refresh_regex =~ @body
|
|
metarefresh = @body.scan(@@meta_refresh_regex).flatten.first
|
|
metarefresh = decode_html_entities(metarefresh).strip
|
|
newtarget_m = URI.join(@target, metarefresh).to_s # this works for relative and absolute
|
|
end
|
|
|
|
# HTTP 3XX redirect
|
|
if (300..399) === @status && @headers && @headers['location']
|
|
|
|
location = @headers['location']
|
|
begin
|
|
newtarget_h = URI.join(@target, location).to_s
|
|
rescue StandardError => err
|
|
# the combinaton of the current target and the new location must be invalid
|
|
error("Error: Invalid redirection from #{@target} to #{location}. #{err}")
|
|
return nil
|
|
end
|
|
|
|
end
|
|
|
|
# if both meta refresh location and HTTP location are set, then the HTTP location overrides
|
|
if newtarget_m || newtarget_h
|
|
case $FOLLOW_REDIRECT
|
|
when 'never'
|
|
# no_redirects = true # this never gets back to main loop but no prob
|
|
when 'http-only'
|
|
newtarget = newtarget_h
|
|
when 'meta-only'
|
|
newtarget = newtarget_m
|
|
when 'same-site'
|
|
newtarget = (newtarget_h || newtarget_m) if URI.parse((newtarget_h || newtarget_m)).host == @uri.host # defaults to _h if both are present
|
|
when 'always'
|
|
newtarget = (newtarget_h || newtarget_m)
|
|
else
|
|
error('Error: Invalid REDIRECT mode')
|
|
end
|
|
end
|
|
newtarget = nil if newtarget == @uri.to_s # circular redirection not allowed
|
|
|
|
newtarget
|
|
end
|
|
end
|