site-inspector 3.2.0 → 4.0.0

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.rubocop.yml +1 -5
  4. data/.rubocop_todo.yml +185 -79
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +71 -0
  7. data/Gemfile +0 -2
  8. data/README.md +8 -14
  9. data/bin/site-inspector +13 -13
  10. data/lib/cliver/dependency_ext.rb +2 -2
  11. data/lib/data/well-known.yml +51 -0
  12. data/lib/site-inspector/domain.rb +27 -15
  13. data/lib/site-inspector/domain_parser.rb +11 -0
  14. data/lib/site-inspector/{checks → endpoint}/accessibility.rb +6 -4
  15. data/lib/site-inspector/{checks → endpoint}/check.rb +5 -1
  16. data/lib/site-inspector/endpoint/content.rb +151 -0
  17. data/lib/site-inspector/{checks → endpoint}/cookies.rb +13 -5
  18. data/lib/site-inspector/{checks → endpoint}/dns.rb +42 -18
  19. data/lib/site-inspector/endpoint/headers.rb +55 -0
  20. data/lib/site-inspector/{checks → endpoint}/hsts.rb +36 -5
  21. data/lib/site-inspector/endpoint/wappalyzer.rb +68 -0
  22. data/lib/site-inspector/endpoint/well_known.rb +95 -0
  23. data/lib/site-inspector/endpoint/whois.rb +56 -0
  24. data/lib/site-inspector/endpoint.rb +60 -23
  25. data/lib/site-inspector/formatter.rb +118 -0
  26. data/lib/site-inspector/version.rb +1 -1
  27. data/lib/site-inspector.rb +34 -30
  28. data/package-lock.json +670 -23
  29. data/package.json +2 -1
  30. data/renovate.json +4 -0
  31. data/script/benchmark +8 -0
  32. data/script/cibuild +2 -2
  33. data/script/console +1 -1
  34. data/script/pa11y-version +1 -1
  35. data/script/release +11 -4
  36. data/script/update-well-known +18 -0
  37. data/site-inspector.gemspec +16 -9
  38. data/spec/cliver/dependency_ext_spec.rb +21 -0
  39. data/spec/{site_inspector_domain_spec.rb → site_inspector/domain_spec.rb} +71 -9
  40. data/spec/{checks/site_inspector_endpoint_accessibility_spec.rb → site_inspector/endpoint/accessibility_spec.rb} +9 -7
  41. data/spec/{checks/site_inspector_endpoint_content_spec.rb → site_inspector/endpoint/content_spec.rb} +14 -4
  42. data/spec/{checks/site_inspector_endpoint_cookies_spec.rb → site_inspector/endpoint/cookies_spec.rb} +27 -14
  43. data/spec/{checks/site_inspector_endpoint_dns_spec.rb → site_inspector/endpoint/dns_spec.rb} +87 -9
  44. data/spec/{checks/site_inspector_endpoint_headers_spec.rb → site_inspector/endpoint/headers_spec.rb} +0 -7
  45. data/spec/{checks/site_inspector_endpoint_hsts_spec.rb → site_inspector/endpoint/hsts_spec.rb} +8 -8
  46. data/spec/site_inspector/endpoint/wappalyzer_spec.rb +20 -0
  47. data/spec/site_inspector/endpoint/well_known_spec.rb +12 -0
  48. data/spec/{checks/site_inspector_endpoint_whois_spec.rb → site_inspector/endpoint/whois_spec.rb} +2 -1
  49. data/spec/{site_inspector_endpoint_spec.rb → site_inspector/endpoint_spec.rb} +59 -3
  50. data/spec/site_inspector_spec.rb +58 -11
  51. data/spec/spec_helper.rb +9 -2
  52. metadata +100 -92
  53. data/.travis.yml +0 -9
  54. data/lib/site-inspector/cache.rb +0 -17
  55. data/lib/site-inspector/checks/content.rb +0 -85
  56. data/lib/site-inspector/checks/headers.rb +0 -68
  57. data/lib/site-inspector/checks/sniffer.rb +0 -67
  58. data/lib/site-inspector/checks/wappalyzer.rb +0 -62
  59. data/lib/site-inspector/checks/whois.rb +0 -36
  60. data/lib/site-inspector/disk_cache.rb +0 -42
  61. data/lib/site-inspector/rails_cache.rb +0 -13
  62. data/spec/checks/site_inspector_endpoint_sniffer_spec.rb +0 -150
  63. data/spec/checks/site_inspector_endpoint_wappalyzer_spec.rb +0 -34
  64. data/spec/site_inspector_cache_spec.rb +0 -15
  65. data/spec/site_inspector_disk_cache_spec.rb +0 -39
  66. /data/lib/site-inspector/{checks → endpoint}/https.rb +0 -0
  67. /data/spec/{checks/site_inspector_endpoint_check_spec.rb → site_inspector/endpoint/check_spec.rb} +0 -0
  68. /data/spec/{checks/site_inspector_endpoint_https_spec.rb → site_inspector/endpoint/https_spec.rb} +0 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ class SiteInspector
6
+ class Endpoint
7
+ class WellKnown < Check
8
+ BASE_PATH = './.well-known/'
9
+
10
+ class << self
11
+ def well_knowns
12
+ data = File.expand_path '../../data/well-known.yml', File.dirname(__FILE__)
13
+ wks = YAML.load_file(data)
14
+ wks.reject { |wk| wk.include?('deprecated') }
15
+ end
16
+
17
+ def keys
18
+ well_knowns.map { |wk| key_for(wk) }
19
+ end
20
+
21
+ def key_paths
22
+ @key_paths ||= begin
23
+ key_paths = well_knowns.map do |wk|
24
+ [key_for(wk), path_for(wk)]
25
+ end
26
+
27
+ key_paths.to_h
28
+ end
29
+ end
30
+
31
+ def path_for(wk)
32
+ return unless well_knowns.include?(wk)
33
+
34
+ "#{BASE_PATH}#{wk}"
35
+ end
36
+
37
+ def key_for(well_known)
38
+ well_known.gsub(/(\.|-)/, '_').to_sym
39
+ end
40
+ end
41
+
42
+ def exists?(path)
43
+ endpoint.content.path_exists?(path)
44
+ end
45
+
46
+ def uri_for(key)
47
+ path = self.class.key_paths[key]
48
+ endpoint.join(path) if path
49
+ end
50
+
51
+ def to_h
52
+ return {} unless endpoint.up?
53
+ return {} if endpoint.redirect?
54
+ return @hash if defined?(@hash)
55
+
56
+ prefetch
57
+ @hash = {}
58
+
59
+ self.class.keys.each do |key|
60
+ @hash[key] = public_send(key)
61
+ end
62
+
63
+ @hash
64
+ end
65
+
66
+ def method_missing(method_sym, *arguments, &)
67
+ if respond_to_missing?(method_sym)
68
+ exists?(self.class.key_paths[method_sym])
69
+ else
70
+ super
71
+ end
72
+ end
73
+
74
+ def respond_to_missing?(method_sym, include_private = false)
75
+ if self.class.keys.include?(method_sym)
76
+ true
77
+ else
78
+ super
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def prefetch
85
+ options = SiteInspector.typhoeus_defaults.merge(followlocation: true)
86
+ self.class.key_paths.each do |_key, path|
87
+ uri = endpoint.join(path)
88
+ request = Typhoeus::Request.new(uri, options)
89
+ SiteInspector.hydra.queue(request)
90
+ end
91
+ SiteInspector.hydra.run
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SiteInspector
4
+ class Endpoint
5
+ class Whois < Check
6
+ def domain
7
+ return @domain if defined? @domain
8
+
9
+ @domain = begin
10
+ whois.lookup host
11
+ rescue Timeout::Error
12
+ nil
13
+ end
14
+ end
15
+
16
+ def ip
17
+ return @ip if defined? @ip
18
+
19
+ @ip = begin
20
+ whois.lookup ip_address if ip_address
21
+ rescue Timeout::Error
22
+ nil
23
+ end
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ domain: record_to_h(domain),
29
+ ip: record_to_h(ip)
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def record_to_h(record)
36
+ return unless record
37
+
38
+ record.content.scan(/^\s*(.*?):\s*(.*?)\r?\n/).to_h
39
+ end
40
+
41
+ def ip_address
42
+ return @ip_address if defined? @ip_address
43
+
44
+ @ip_address = begin
45
+ Resolv.getaddress host
46
+ rescue Resolv::ResolvError
47
+ nil
48
+ end
49
+ end
50
+
51
+ def whois
52
+ @whois ||= ::Whois::Client.new
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'site-inspector/endpoint/check'
4
+ require 'site-inspector/endpoint/accessibility'
5
+ require 'site-inspector/endpoint/content'
6
+ require 'site-inspector/endpoint/dns'
7
+ require 'site-inspector/endpoint/headers'
8
+ require 'site-inspector/endpoint/hsts'
9
+ require 'site-inspector/endpoint/https'
10
+ require 'site-inspector/endpoint/cookies'
11
+ require 'site-inspector/endpoint/well_known'
12
+ require 'site-inspector/endpoint/whois'
13
+ require 'site-inspector/endpoint/wappalyzer'
14
+
3
15
  class SiteInspector
4
16
  # Every domain has four possible "endpoints" to evaluate
5
17
  #
@@ -14,23 +26,32 @@ class SiteInspector
14
26
  class Endpoint
15
27
  attr_accessor :host, :uri, :domain
16
28
 
29
+ def self.build(domain, https: true, www: false)
30
+ uri = Addressable::URI.new
31
+ uri.host = www ? "www.#{domain.host}" : domain.host.to_s
32
+ uri.scheme = https ? 'https' : 'http'
33
+
34
+ new(uri.to_s, domain:)
35
+ end
36
+
17
37
  # Initatiate a new Endpoint object
18
38
  #
19
39
  # endpoint - (string) the endpoint to query (e.g., `https://example.com`)
20
40
  # options - A hash of options
21
41
  # domain - the parent domain object, if passed, facilitates caching of redirects
22
42
  def initialize(host, options = {})
23
- @uri = Addressable::URI.parse(host.downcase)
43
+ host = host.downcase if host.is_a?(String)
44
+ @uri = Addressable::URI.parse(host)
24
45
  # The root URL always has an implict path of "/", even if not requested
25
46
  # Make it explicit to facilitate caching and prevent a potential redirect
26
47
  @uri.path = '/'
27
- @host = uri.host.sub(/^www\./, '')
48
+ @host = DomainParser.parse(host)
28
49
  @checks = {}
29
50
  @domain = options[:domain]
30
51
  end
31
52
 
32
53
  def www?
33
- !!(uri.host =~ /^www\./)
54
+ @host.trd == 'www'
34
55
  end
35
56
 
36
57
  def root?
@@ -50,13 +71,21 @@ class SiteInspector
50
71
  end
51
72
 
52
73
  def request(options = {})
53
- target = options[:path] ? URI.join(uri, options.delete(:path)) : uri
54
- request = Typhoeus::Request.new(target, SiteInspector.typhoeus_defaults.merge(options))
74
+ request = build_request(options)
55
75
  hydra.queue(request)
56
76
  hydra.run
57
77
  request.response
58
78
  end
59
79
 
80
+ def build_request(options)
81
+ target = options[:path] ? join(options.delete(:path)) : uri
82
+ Typhoeus::Request.new(target, SiteInspector.typhoeus_defaults.merge(options))
83
+ end
84
+
85
+ def join(*args)
86
+ URI.join(uri, *args)
87
+ end
88
+
60
89
  # Makes a GET request of the given host
61
90
  #
62
91
  # Retutns the Typhoeus::Response object
@@ -74,7 +103,9 @@ class SiteInspector
74
103
 
75
104
  # Does the endpoint return a 2xx or 3xx response code?
76
105
  def up?
77
- response && response_code.start_with?('2') || response_code.start_with?('3')
106
+ return false unless response
107
+
108
+ response_code.start_with?('2', '3')
78
109
  end
79
110
 
80
111
  # Does the server respond at all?
@@ -86,18 +117,16 @@ class SiteInspector
86
117
  def redirect
87
118
  return unless response && response_code.start_with?('3')
88
119
 
89
- @redirect ||= begin
90
- redirect = Addressable::URI.parse(headers['location'])
120
+ location = headers['location']
121
+ return if location.nil? || location.empty?
91
122
 
92
- # This is a relative redirect, but we still need the absolute URI
93
- if redirect.relative?
94
- redirect.path = "/#{redirect.path}" unless redirect.path[0] == '/'
95
- redirect.host = host
96
- redirect.scheme = scheme
97
- end
123
+ @redirect ||= begin
124
+ # Resolve per RFC 3986, so protocol-relative (`//host`), query-only
125
+ # (`?q`), and dot-segment (`../x`) locations become absolute URIs
126
+ redirect = uri.join(location)
98
127
 
99
128
  # This was a redirect to a subpath or back to itself, which we don't care about
100
- return if redirect.host == host && redirect.scheme == scheme
129
+ return if redirect.host == host.to_s && redirect.scheme == scheme
101
130
 
102
131
  # Init a new endpoint representing the redirect
103
132
  find_or_create_by_uri(redirect.to_s)
@@ -132,7 +161,7 @@ class SiteInspector
132
161
  end
133
162
 
134
163
  def external_redirect?
135
- host != resolves_to.host
164
+ host.to_s != resolves_to.host.to_s
136
165
  end
137
166
 
138
167
  def to_s
@@ -153,16 +182,19 @@ class SiteInspector
153
182
  #
154
183
  # Returns the hash representing the endpoint and its checks
155
184
  def to_h(options = {})
156
- hash = {
185
+ return @hash if defined?(@hash) && options == {}
186
+
187
+ @hash = {
157
188
  uri: uri.to_s,
158
- host: host,
189
+ host: host.to_s,
159
190
  www: www?,
160
191
  https: https?,
161
- scheme: scheme,
192
+ scheme:,
162
193
  up: up?,
163
194
  responds: responds?,
164
195
  timed_out: timed_out?,
165
196
  redirect: redirect?,
197
+ resolves_to: resolves_to.to_s,
166
198
  external_redirect: external_redirect?
167
199
  }
168
200
 
@@ -170,11 +202,11 @@ class SiteInspector
170
202
  checks = SiteInspector::Endpoint.checks.select { |c| options.key?(c.name) }
171
203
  checks = SiteInspector::Endpoint.checks if checks.empty?
172
204
 
173
- Parallel.each(checks, in_threads: 4) do |check|
174
- hash[check.name] = send(check.name).to_h
205
+ Parallel.each(checks, in_processes: 0) do |check|
206
+ @hash[check.name] = send(check.name).to_h
175
207
  end
176
208
 
177
- hash
209
+ @hash
178
210
  end
179
211
 
180
212
  def self.checks
@@ -183,7 +215,7 @@ class SiteInspector
183
215
  @checks = ObjectSpace.each_object(Class).select { |klass| klass < Check }.select(&:enabled?).sort_by(&:name)
184
216
  end
185
217
 
186
- def method_missing(method_sym, *arguments, &block)
218
+ def method_missing(method_sym, *arguments, &)
187
219
  check = SiteInspector::Endpoint.checks.find { |c| c.name == method_sym }
188
220
  if check
189
221
  @checks[method_sym] ||= check.new(self)
@@ -200,6 +232,11 @@ class SiteInspector
200
232
  end
201
233
  end
202
234
 
235
+ def eql?(other)
236
+ other.class == self.class && other.uri == uri
237
+ end
238
+ alias == eql?
239
+
203
240
  private
204
241
 
205
242
  def hydra
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+ require 'nokogiri'
5
+
6
+ class SiteInspector
7
+ module Formatter
8
+ ACRONYMS = %w[
9
+ acme cdn cms crm dns dnssec dnt hsts http https id ip ipv6 json paas pki sld ssl tld tls trd txt ui uri url whois www xml xss
10
+ ].freeze
11
+
12
+ def format_table(hash)
13
+ build_fragment do |f|
14
+ f.table(class: 'table table-striped') do
15
+ hash.each do |key, value|
16
+ f << format_key_value(key, value)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ def format_key(string)
23
+ capitalize_acronyms(string.to_s.gsub(/^x-/, '').tr('-', ' ').humanize)
24
+ end
25
+
26
+ def format_value(value)
27
+ case value
28
+ when Hash
29
+ format_hash(value)
30
+ when Array
31
+ format_array(value)
32
+ else
33
+ format_string(value)
34
+ end
35
+ end
36
+
37
+ def format_key_value(key, value, check = nil)
38
+ c = class_for_value(value)
39
+
40
+ build_fragment do |f|
41
+ f.tr do
42
+ f.th { f.text format_key(key) }
43
+ f.td(c) do
44
+ uri = check&.uri_for(key)
45
+
46
+ if value == true && uri
47
+ f.a({ href: uri }.merge(c)) { f << format_value(value) }
48
+ else
49
+ f << format_value(value)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def class_for_value(value)
59
+ if value.instance_of?(TrueClass)
60
+ { class: 'text-success' }
61
+ elsif value.instance_of?(FalseClass)
62
+ { class: 'text-danger' }
63
+ else
64
+ {}
65
+ end
66
+ end
67
+
68
+ def build_fragment(&block)
69
+ fragment = Nokogiri::HTML::DocumentFragment.parse ''
70
+ Nokogiri::HTML::Builder.with(fragment, &block)
71
+ fragment.to_s
72
+ end
73
+
74
+ def capitalize_acronyms(string)
75
+ string.gsub(/\b(#{ACRONYMS.join("|")})\b/i) do
76
+ Regexp.last_match(1).to_s.upcase
77
+ end
78
+ end
79
+
80
+ def format_string(value)
81
+ value = CGI.escapeHTML(value.to_s)
82
+
83
+ if %r{^https?:/}.match?(value)
84
+ value = build_fragment do |f|
85
+ f.a(href: value) { f.text value }
86
+ end
87
+ end
88
+
89
+ value
90
+ end
91
+
92
+ def format_hash(hash)
93
+ build_fragment do |f|
94
+ f.ul do
95
+ hash.each do |key, value|
96
+ f.li do
97
+ f.span(class: 'font-weight-bold') { f.text format_key(key) }
98
+ f.text ': '
99
+ f << format_value(value)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def format_array(array)
107
+ return format_value(array[0]) if array.length == 1
108
+
109
+ build_fragment do |f|
110
+ f.ol do
111
+ array.each do |el|
112
+ f.li { f << format_value(el) }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SiteInspector
4
- VERSION = '3.2.0'
4
+ VERSION = '4.0.0'
5
5
  end
@@ -4,43 +4,37 @@ require 'open-uri'
4
4
  require 'addressable/uri'
5
5
  require 'public_suffix'
6
6
  require 'typhoeus'
7
+ require 'typhoeus/cache/rails'
8
+ require 'active_support'
9
+ require 'active_support/cache'
7
10
  require 'parallel'
8
11
  require 'cliver'
9
12
  require 'whois'
10
13
  require 'cgi'
11
14
  require 'resolv'
12
- require 'dotenv/load'
13
-
14
- require_relative 'site-inspector/cache'
15
- require_relative 'site-inspector/disk_cache'
16
- require_relative 'site-inspector/rails_cache'
17
- require_relative 'site-inspector/domain'
18
- require_relative 'site-inspector/checks/check'
19
- require_relative 'site-inspector/checks/accessibility'
20
- require_relative 'site-inspector/checks/content'
21
- require_relative 'site-inspector/checks/dns'
22
- require_relative 'site-inspector/checks/headers'
23
- require_relative 'site-inspector/checks/hsts'
24
- require_relative 'site-inspector/checks/https'
25
- require_relative 'site-inspector/checks/sniffer'
26
- require_relative 'site-inspector/checks/cookies'
27
- require_relative 'site-inspector/checks/whois'
28
- require_relative 'site-inspector/checks/wappalyzer'
29
- require_relative 'site-inspector/endpoint'
30
- require_relative 'site-inspector/version'
15
+ require 'naughty_or_nice'
31
16
  require_relative 'cliver/dependency_ext'
32
17
 
33
18
  class SiteInspector
19
+ autoload :Formatter, 'site-inspector/formatter'
20
+ autoload :Domain, 'site-inspector/domain'
21
+ autoload :DomainParser, 'site-inspector/domain_parser'
22
+ autoload :Endpoint, 'site-inspector/endpoint'
23
+ autoload :VERSION, 'site-inspector/version'
24
+
34
25
  class << self
35
26
  attr_writer :timeout, :cache, :typhoeus_options
36
27
 
28
+ # The Typhoeus response cache. Set CACHE to a directory to persist
29
+ # responses to disk; inside Rails, Rails.cache is used; otherwise
30
+ # responses are kept in memory for the life of the process.
37
31
  def cache
38
32
  @cache ||= if ENV['CACHE']
39
- SiteInspector::DiskCache.new
40
- elsif Object.const_defined?('Rails')
41
- SiteInspector::RailsCache.new
33
+ Typhoeus::Cache::Rails.new(ActiveSupport::Cache::FileStore.new(ENV.fetch('CACHE')))
34
+ elsif Object.const_defined?(:Rails)
35
+ Typhoeus::Cache::Rails.new
42
36
  else
43
- SiteInspector::Cache.new
37
+ Typhoeus::Cache::Rails.new(ActiveSupport::Cache::MemoryStore.new)
44
38
  end
45
39
  end
46
40
 
@@ -57,10 +51,7 @@ class SiteInspector
57
51
  followlocation: false,
58
52
  timeout: SiteInspector.timeout,
59
53
  accept_encoding: 'gzip',
60
- method: :head,
61
- headers: {
62
- 'User-Agent' => "Mozilla/5.0 (compatible; SiteInspector/#{SiteInspector::VERSION}; +https://github.com/benbalter/site-inspector)"
63
- }
54
+ method: :head
64
55
  }
65
56
  defaults.merge! @typhoeus_options if @typhoeus_options
66
57
  defaults
@@ -68,16 +59,29 @@ class SiteInspector
68
59
 
69
60
  # Returns a thread-safe, memoized hydra instance
70
61
  def hydra
71
- Typhoeus::Hydra.hydra
62
+ @hydra ||= Typhoeus::Hydra.hydra
63
+ end
64
+
65
+ def logger
66
+ @logger ||= Logger.new($stdout)
72
67
  end
73
68
  end
74
69
  end
75
70
 
76
71
  if ENV['DEBUG']
77
- Ethon.logger = Logger.new($stdout)
72
+ Ethon.logger = SiteInspector.logger
78
73
  Ethon.logger.level = Logger::DEBUG
79
74
  Typhoeus::Config.verbose = true
80
75
  end
81
76
 
82
- Typhoeus::Config.memoize = true
83
77
  Typhoeus::Config.cache = SiteInspector.cache
78
+ Typhoeus::Config.memoize = true
79
+ Typhoeus::Config.user_agent = "Mozilla/5.0 (compatible; SiteInspector/#{SiteInspector::VERSION}; +https://github.com/benbalter/site-inspector)"
80
+
81
+ if ENV['VERBOSE']
82
+ Typhoeus.before do |req|
83
+ hit = Typhoeus::Config.cache.get(req)
84
+ SiteInspector.logger.info "#{req.options[:method]} #{req.base_url} (CACHE #{hit ? 'HIT' : 'MISS'})"
85
+ req
86
+ end
87
+ end