site-inspector 3.1.1 → 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 (73) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +30 -0
  3. data/.gitignore +1 -1
  4. data/.rubocop.yml +14 -10
  5. data/.rubocop_todo.yml +245 -0
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +71 -0
  8. data/Gemfile +2 -0
  9. data/Guardfile +2 -0
  10. data/README.md +8 -14
  11. data/Rakefile +2 -0
  12. data/bin/site-inspector +16 -15
  13. data/lib/cliver/dependency_ext.rb +8 -5
  14. data/lib/data/well-known.yml +51 -0
  15. data/lib/site-inspector/domain.rb +61 -43
  16. data/lib/site-inspector/domain_parser.rb +11 -0
  17. data/lib/site-inspector/{checks → endpoint}/accessibility.rb +35 -25
  18. data/lib/site-inspector/{checks → endpoint}/check.rb +9 -3
  19. data/lib/site-inspector/endpoint/content.rb +151 -0
  20. data/lib/site-inspector/endpoint/cookies.rb +53 -0
  21. data/lib/site-inspector/{checks → endpoint}/dns.rb +52 -26
  22. data/lib/site-inspector/endpoint/headers.rb +55 -0
  23. data/lib/site-inspector/endpoint/hsts.rb +112 -0
  24. data/lib/site-inspector/{checks → endpoint}/https.rb +3 -1
  25. data/lib/site-inspector/endpoint/wappalyzer.rb +68 -0
  26. data/lib/site-inspector/endpoint/well_known.rb +95 -0
  27. data/lib/site-inspector/endpoint/whois.rb +56 -0
  28. data/lib/site-inspector/endpoint.rb +80 -44
  29. data/lib/site-inspector/formatter.rb +118 -0
  30. data/lib/site-inspector/version.rb +3 -1
  31. data/lib/site-inspector.rb +42 -31
  32. data/package-lock.json +1152 -0
  33. data/package.json +2 -1
  34. data/renovate.json +4 -0
  35. data/script/benchmark +8 -0
  36. data/script/cibuild +2 -2
  37. data/script/console +1 -1
  38. data/script/pa11y-version +2 -1
  39. data/script/release +11 -4
  40. data/script/update-well-known +18 -0
  41. data/site-inspector.gemspec +33 -19
  42. data/spec/cliver/dependency_ext_spec.rb +21 -0
  43. data/spec/fixtures/wappalyzer.json +125 -0
  44. data/spec/{site_inspector_domain_spec.rb → site_inspector/domain_spec.rb} +105 -43
  45. data/spec/{checks/site_inspector_endpoint_accessibility_spec.rb → site_inspector/endpoint/accessibility_spec.rb} +24 -20
  46. data/spec/{checks/site_inspector_endpoint_check_spec.rb → site_inspector/endpoint/check_spec.rb} +9 -7
  47. data/spec/{checks/site_inspector_endpoint_content_spec.rb → site_inspector/endpoint/content_spec.rb} +43 -24
  48. data/spec/{checks/site_inspector_endpoint_cookies_spec.rb → site_inspector/endpoint/cookies_spec.rb} +37 -22
  49. data/spec/site_inspector/endpoint/dns_spec.rb +262 -0
  50. data/spec/{checks/site_inspector_endpoint_headers_spec.rb → site_inspector/endpoint/headers_spec.rb} +10 -15
  51. data/spec/site_inspector/endpoint/hsts_spec.rb +92 -0
  52. data/spec/{checks/site_inspector_endpoint_https_spec.rb → site_inspector/endpoint/https_spec.rb} +12 -10
  53. data/spec/site_inspector/endpoint/wappalyzer_spec.rb +20 -0
  54. data/spec/site_inspector/endpoint/well_known_spec.rb +12 -0
  55. data/spec/site_inspector/endpoint/whois_spec.rb +27 -0
  56. data/spec/{site_inspector_endpoint_spec.rb → site_inspector/endpoint_spec.rb} +102 -45
  57. data/spec/site_inspector_spec.rb +67 -18
  58. data/spec/spec_helper.rb +11 -2
  59. metadata +178 -100
  60. data/.travis.yml +0 -9
  61. data/lib/site-inspector/cache.rb +0 -15
  62. data/lib/site-inspector/checks/content.rb +0 -74
  63. data/lib/site-inspector/checks/cookies.rb +0 -43
  64. data/lib/site-inspector/checks/headers.rb +0 -66
  65. data/lib/site-inspector/checks/hsts.rb +0 -77
  66. data/lib/site-inspector/checks/sniffer.rb +0 -64
  67. data/lib/site-inspector/disk_cache.rb +0 -40
  68. data/lib/site-inspector/rails_cache.rb +0 -11
  69. data/spec/checks/site_inspector_endpoint_dns_spec.rb +0 -182
  70. data/spec/checks/site_inspector_endpoint_hsts_spec.rb +0 -90
  71. data/spec/checks/site_inspector_endpoint_sniffer_spec.rb +0 -148
  72. data/spec/site_inspector_cache_spec.rb +0 -13
  73. data/spec/site_inspector_disk_cache_spec.rb +0 -37
@@ -1,3 +1,17 @@
1
+ # frozen_string_literal: true
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
+
1
15
  class SiteInspector
2
16
  # Every domain has four possible "endpoints" to evaluate
3
17
  #
@@ -12,23 +26,32 @@ class SiteInspector
12
26
  class Endpoint
13
27
  attr_accessor :host, :uri, :domain
14
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
+
15
37
  # Initatiate a new Endpoint object
16
38
  #
17
39
  # endpoint - (string) the endpoint to query (e.g., `https://example.com`)
18
40
  # options - A hash of options
19
41
  # domain - the parent domain object, if passed, facilitates caching of redirects
20
42
  def initialize(host, options = {})
21
- @uri = Addressable::URI.parse(host.downcase)
43
+ host = host.downcase if host.is_a?(String)
44
+ @uri = Addressable::URI.parse(host)
22
45
  # The root URL always has an implict path of "/", even if not requested
23
46
  # Make it explicit to facilitate caching and prevent a potential redirect
24
47
  @uri.path = '/'
25
- @host = uri.host.sub(/^www\./, '')
48
+ @host = DomainParser.parse(host)
26
49
  @checks = {}
27
50
  @domain = options[:domain]
28
51
  end
29
52
 
30
53
  def www?
31
- !!(uri.host =~ /^www\./)
54
+ @host.trd == 'www'
32
55
  end
33
56
 
34
57
  def root?
@@ -48,13 +71,21 @@ class SiteInspector
48
71
  end
49
72
 
50
73
  def request(options = {})
51
- target = options[:path] ? URI.join(uri, options.delete(:path)) : uri
52
- request = Typhoeus::Request.new(target, SiteInspector.typhoeus_defaults.merge(options))
74
+ request = build_request(options)
53
75
  hydra.queue(request)
54
76
  hydra.run
55
77
  request.response
56
78
  end
57
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
+
58
89
  # Makes a GET request of the given host
59
90
  #
60
91
  # Retutns the Typhoeus::Response object
@@ -67,12 +98,14 @@ class SiteInspector
67
98
  end
68
99
 
69
100
  def timed_out?
70
- response && response.timed_out?
101
+ response&.timed_out?
71
102
  end
72
103
 
73
104
  # Does the endpoint return a 2xx or 3xx response code?
74
105
  def up?
75
- response && response_code.start_with?('2') || response_code.start_with?('3')
106
+ return false unless response
107
+
108
+ response_code.start_with?('2', '3')
76
109
  end
77
110
 
78
111
  # Does the server respond at all?
@@ -84,18 +117,16 @@ class SiteInspector
84
117
  def redirect
85
118
  return unless response && response_code.start_with?('3')
86
119
 
87
- @redirect ||= begin
88
- redirect = Addressable::URI.parse(headers['location'])
120
+ location = headers['location']
121
+ return if location.nil? || location.empty?
89
122
 
90
- # This is a relative redirect, but we still need the absolute URI
91
- if redirect.relative?
92
- redirect.path = "/#{redirect.path}" unless redirect.path[0] == '/'
93
- redirect.host = host
94
- redirect.scheme = scheme
95
- 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)
96
127
 
97
128
  # This was a redirect to a subpath or back to itself, which we don't care about
98
- return if redirect.host == host && redirect.scheme == scheme
129
+ return if redirect.host == host.to_s && redirect.scheme == scheme
99
130
 
100
131
  # Init a new endpoint representing the redirect
101
132
  find_or_create_by_uri(redirect.to_s)
@@ -119,22 +150,18 @@ class SiteInspector
119
150
  response = request(followlocation: true)
120
151
 
121
152
  # Workaround for Webmock not playing nicely with Typhoeus redirects
122
- if response.mock?
123
- if response.headers['Location']
124
- url = response.headers['Location']
125
- else
126
- url = response.request.url
127
- end
128
- else
129
- url = response.effective_url
130
- end
153
+ url = if response.mock?
154
+ response.headers['Location'] || response.request.url
155
+ else
156
+ response.effective_url
157
+ end
131
158
 
132
159
  find_or_create_by_uri(url)
133
160
  end
134
161
  end
135
162
 
136
163
  def external_redirect?
137
- host != resolves_to.host
164
+ host.to_s != resolves_to.host.to_s
138
165
  end
139
166
 
140
167
  def to_s
@@ -155,36 +182,40 @@ class SiteInspector
155
182
  #
156
183
  # Returns the hash representing the endpoint and its checks
157
184
  def to_h(options = {})
158
- hash = {
159
- uri: uri.to_s,
160
- host: host,
161
- www: www?,
162
- https: https?,
163
- scheme: scheme,
164
- up: up?,
165
- responds: responds?,
166
- timed_out: timed_out?,
167
- redirect: redirect?,
185
+ return @hash if defined?(@hash) && options == {}
186
+
187
+ @hash = {
188
+ uri: uri.to_s,
189
+ host: host.to_s,
190
+ www: www?,
191
+ https: https?,
192
+ scheme:,
193
+ up: up?,
194
+ responds: responds?,
195
+ timed_out: timed_out?,
196
+ redirect: redirect?,
197
+ resolves_to: resolves_to.to_s,
168
198
  external_redirect: external_redirect?
169
199
  }
170
200
 
171
201
  # Either they've specifically asked for a check, or we throw everything at them
172
- checks = SiteInspector::Endpoint.checks.select { |c| options.keys.include?(c.name) }
202
+ checks = SiteInspector::Endpoint.checks.select { |c| options.key?(c.name) }
173
203
  checks = SiteInspector::Endpoint.checks if checks.empty?
174
204
 
175
- Parallel.each(checks, in_threads: 4) do |check|
176
- 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
177
207
  end
178
208
 
179
- hash
209
+ @hash
180
210
  end
181
211
 
182
212
  def self.checks
183
213
  return @checks if defined? @checks
184
- @checks = ObjectSpace.each_object(Class).select { |klass| klass < Check }.select(&:enabled?)
214
+
215
+ @checks = ObjectSpace.each_object(Class).select { |klass| klass < Check }.select(&:enabled?).sort_by(&:name)
185
216
  end
186
217
 
187
- def method_missing(method_sym, *arguments, &block)
218
+ def method_missing(method_sym, *arguments, &)
188
219
  check = SiteInspector::Endpoint.checks.find { |c| c.name == method_sym }
189
220
  if check
190
221
  @checks[method_sym] ||= check.new(self)
@@ -193,14 +224,19 @@ class SiteInspector
193
224
  end
194
225
  end
195
226
 
196
- def respond_to?(method_sym, include_private = false)
197
- if checks.keys.include?(method_sym)
227
+ def respond_to_missing?(method_sym, include_private = false)
228
+ if checks.key?(method_sym)
198
229
  true
199
230
  else
200
231
  super
201
232
  end
202
233
  end
203
234
 
235
+ def eql?(other)
236
+ other.class == self.class && other.uri == uri
237
+ end
238
+ alias == eql?
239
+
204
240
  private
205
241
 
206
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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class SiteInspector
2
- VERSION = '3.1.1'
4
+ VERSION = '4.0.0'
3
5
  end
@@ -1,40 +1,41 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'open-uri'
2
4
  require 'addressable/uri'
3
5
  require 'public_suffix'
4
6
  require 'typhoeus'
7
+ require 'typhoeus/cache/rails'
8
+ require 'active_support'
9
+ require 'active_support/cache'
5
10
  require 'parallel'
6
11
  require 'cliver'
12
+ require 'whois'
7
13
  require 'cgi'
8
-
9
- require_relative 'site-inspector/cache'
10
- require_relative 'site-inspector/disk_cache'
11
- require_relative 'site-inspector/rails_cache'
12
- require_relative 'site-inspector/domain'
13
- require_relative 'site-inspector/checks/check'
14
- require_relative 'site-inspector/checks/accessibility'
15
- require_relative 'site-inspector/checks/content'
16
- require_relative 'site-inspector/checks/dns'
17
- require_relative 'site-inspector/checks/headers'
18
- require_relative 'site-inspector/checks/hsts'
19
- require_relative 'site-inspector/checks/https'
20
- require_relative 'site-inspector/checks/sniffer'
21
- require_relative 'site-inspector/checks/cookies'
22
- require_relative 'site-inspector/endpoint'
23
- require_relative 'site-inspector/version'
14
+ require 'resolv'
15
+ require 'naughty_or_nice'
24
16
  require_relative 'cliver/dependency_ext'
25
17
 
26
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
+
27
25
  class << self
28
26
  attr_writer :timeout, :cache, :typhoeus_options
29
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.
30
31
  def cache
31
32
  @cache ||= if ENV['CACHE']
32
- SiteInspector::DiskCache.new
33
- elsif Object.const_defined?('Rails')
34
- SiteInspector::RailsCache.new
35
- else
36
- SiteInspector::Cache.new
37
- end
33
+ Typhoeus::Cache::Rails.new(ActiveSupport::Cache::FileStore.new(ENV.fetch('CACHE')))
34
+ elsif Object.const_defined?(:Rails)
35
+ Typhoeus::Cache::Rails.new
36
+ else
37
+ Typhoeus::Cache::Rails.new(ActiveSupport::Cache::MemoryStore.new)
38
+ end
38
39
  end
39
40
 
40
41
  def timeout
@@ -47,13 +48,10 @@ class SiteInspector
47
48
 
48
49
  def typhoeus_defaults
49
50
  defaults = {
50
- followlocation: false,
51
- timeout: SiteInspector.timeout,
51
+ followlocation: false,
52
+ timeout: SiteInspector.timeout,
52
53
  accept_encoding: 'gzip',
53
- method: :head,
54
- headers: {
55
- 'User-Agent' => "Mozilla/5.0 (compatible; SiteInspector/#{SiteInspector::VERSION}; +https://github.com/benbalter/site-inspector)"
56
- }
54
+ method: :head
57
55
  }
58
56
  defaults.merge! @typhoeus_options if @typhoeus_options
59
57
  defaults
@@ -61,16 +59,29 @@ class SiteInspector
61
59
 
62
60
  # Returns a thread-safe, memoized hydra instance
63
61
  def hydra
64
- Typhoeus::Hydra.hydra
62
+ @hydra ||= Typhoeus::Hydra.hydra
63
+ end
64
+
65
+ def logger
66
+ @logger ||= Logger.new($stdout)
65
67
  end
66
68
  end
67
69
  end
68
70
 
69
71
  if ENV['DEBUG']
70
- Ethon.logger = Logger.new(STDOUT)
72
+ Ethon.logger = SiteInspector.logger
71
73
  Ethon.logger.level = Logger::DEBUG
72
74
  Typhoeus::Config.verbose = true
73
75
  end
74
76
 
75
- Typhoeus::Config.memoize = true
76
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