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,8 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
1
5
  class SiteInspector
2
6
  class Endpoint
3
7
  class Dns < Check
4
8
  class LocalhostError < StandardError; end
5
9
 
10
+ # Record types fetched for #records. ANY queries are deprecated
11
+ # (RFC 8482) and most resolvers answer them with a single HINFO record.
12
+ RECORD_TYPES = %w[A AAAA CNAME MX DNSKEY].freeze
13
+
6
14
  def self.resolver
7
15
  require 'dnsruby'
8
16
  @resolver ||= begin
@@ -12,20 +20,21 @@ class SiteInspector
12
20
  end
13
21
  end
14
22
 
15
- def query(type = 'ANY')
16
- SiteInspector::Endpoint::Dns.resolver.query(host.to_s, type).answer
17
- rescue Dnsruby::ResolvTimeout, Dnsruby::ServFail, Dnsruby::NXDomain
18
- []
23
+ def query(type = 'A')
24
+ lookup(host.to_s, type)
19
25
  end
20
26
 
21
27
  def records
22
- @records ||= query
28
+ @records ||= RECORD_TYPES.flat_map { |type| query(type) }.uniq(&:to_s)
23
29
  end
24
30
 
25
31
  def record?(type)
26
- records.any? { |record| record.type == type } || query(type).count != 0
32
+ return true if records.any? { |record| record.type == type }
33
+ return false if RECORD_TYPES.include?(type.to_s)
34
+
35
+ query(type).any?
27
36
  end
28
- alias_method :has_record?, :record?
37
+ alias has_record? record?
29
38
 
30
39
  def dnssec?
31
40
  @dnssec ||= has_record? 'DNSKEY'
@@ -52,27 +61,36 @@ class SiteInspector
52
61
  end
53
62
 
54
63
  def google_apps?
55
- @google ||= records.any? do |record|
64
+ @google_apps ||= records.any? do |record|
56
65
  record.type == 'MX' && record.exchange.to_s =~ /google(mail)?\.com\.?\z/i
57
66
  end
58
67
  end
59
68
 
60
69
  def localhost?
61
- ip == '127.0.0.1'
70
+ return false unless ip
71
+
72
+ IPAddr.new(ip).loopback?
73
+ rescue IPAddr::InvalidAddressError
74
+ false
62
75
  end
63
76
 
77
+ # The host's first IPv4 address, falling back to IPv6
64
78
  def ip
65
- require 'resolv'
66
- @ip ||= Resolv.getaddress host
67
- rescue Resolv::ResolvError
68
- nil
79
+ @ip ||= begin
80
+ record = records.find { |r| r.type == 'A' } || records.find { |r| r.type == 'AAAA' }
81
+ record&.address&.to_s
82
+ end
69
83
  end
70
84
 
85
+ # The PTR hostname for #ip
71
86
  def hostname
72
- require 'resolv'
73
- @hostname ||= PublicSuffix.parse(Resolv.getname(ip))
74
- rescue Resolv::ResolvError, PublicSuffix::DomainInvalid
75
- nil
87
+ return @hostname if defined?(@hostname)
88
+ return @hostname = nil unless ip
89
+
90
+ ptr = lookup(ip, 'PTR').find { |record| record.type == 'PTR' }
91
+ @hostname = ptr ? PublicSuffix.parse(ptr.domainname.to_s) : nil
92
+ rescue PublicSuffix::DomainInvalid
93
+ @hostname = nil
76
94
  end
77
95
 
78
96
  def cnames
@@ -87,19 +105,27 @@ class SiteInspector
87
105
 
88
106
  def to_h
89
107
  return { error: LocalhostError } if localhost?
108
+
90
109
  {
91
- dnssec: dnssec?,
92
- ipv6: ipv6?,
93
- cdn: cdn,
94
- cloud_provider: cloud_provider,
95
- google_apps: google_apps?,
96
- hostname: hostname,
97
- ip: ip
110
+ dnssec: dnssec?,
111
+ ipv6: ipv6?,
112
+ cdn:,
113
+ cloud_provider:,
114
+ google_apps: google_apps?,
115
+ hostname: hostname.to_s,
116
+ ip:
98
117
  }
99
118
  end
100
119
 
101
120
  private
102
121
 
122
+ def lookup(name, type)
123
+ SiteInspector::Endpoint::Dns.resolver.query(name, type).answer
124
+ rescue Dnsruby::ResolvTimeout, Dnsruby::ResolvError => e
125
+ SiteInspector.logger.warn e.message
126
+ []
127
+ end
128
+
103
129
  def data
104
130
  @data ||= {}
105
131
  end
@@ -118,7 +144,7 @@ class SiteInspector
118
144
  haystack = load_data(type)
119
145
  needle = haystack.find do |_name, domain|
120
146
  cnames.any? do |cname|
121
- domain == cname.tld || domain == "#{cname.sld}.#{cname.tld}"
147
+ [cname.tld, "#{cname.sld}.#{cname.tld}"].include? domain
122
148
  end
123
149
  end
124
150
 
@@ -126,7 +152,7 @@ class SiteInspector
126
152
  return nil unless hostname
127
153
 
128
154
  needle = haystack.find do |_name, domain|
129
- domain == hostname.tld || domain == "#{hostname.sld}.#{hostname.tld}"
155
+ [hostname.tld, "#{hostname.sld}.#{hostname.tld}"].include? domain
130
156
  end
131
157
 
132
158
  needle ? needle[0].to_sym : nil
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SiteInspector
4
+ class Endpoint
5
+ class Headers < Check
6
+ COMMON = %w[
7
+ strict-transport-security
8
+ content-security-policy
9
+ x-frame-options
10
+ server
11
+ x-xss-protection
12
+ ].freeze
13
+
14
+ COMMON.each do |header|
15
+ name = header.gsub(/^x-/, '').tr('-', '_')
16
+
17
+ define_method name do
18
+ headers[header]
19
+ end
20
+
21
+ define_method "#{name}?" do
22
+ !!headers[header]
23
+ end
24
+ end
25
+
26
+ # more specific checks than presence of headers
27
+ def xss_protection?
28
+ xss_protection == '1; mode=block'
29
+ end
30
+
31
+ # Returns an array of hashes of downcased key/value header pairs (or an empty hash)
32
+ def all
33
+ @all ||= response&.headers ? response.headers.transform_keys(&:downcase) : {}
34
+ end
35
+ alias headers all
36
+
37
+ def custom_headers
38
+ @custom_headers ||= all.select do |header|
39
+ header.start_with?('x-') && !COMMON.include?(header)
40
+ end
41
+ end
42
+
43
+ def [](header)
44
+ headers[header]
45
+ end
46
+
47
+ def to_h
48
+ return {} if endpoint.redirect?
49
+
50
+ hash = COMMON.to_h { |h| [h, headers[h] || false] }
51
+ hash.merge custom_headers
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SiteInspector
4
+ class Endpoint
5
+ # Utility parser for HSTS headers.
6
+ # RFC: http://tools.ietf.org/html/rfc6797
7
+ class Hsts < Check
8
+ STATUS_ENDPOINT = 'https://hstspreload.org/api/v2/status'
9
+
10
+ def valid?
11
+ return false if header.empty?
12
+
13
+ pairs.none? { |key, value| "#{key}#{value}" =~ /[\s'"]/ }
14
+ end
15
+
16
+ def max_age
17
+ pairs[:'max-age'].to_i
18
+ end
19
+
20
+ def include_subdomains?
21
+ pairs.key?(:includesubdomains)
22
+ end
23
+
24
+ def preload?
25
+ pairs.key?(:preload)
26
+ end
27
+
28
+ def enabled?
29
+ return false unless max_age
30
+
31
+ max_age.positive?
32
+ end
33
+
34
+ # Google's minimum max-age for automatic preloading
35
+ def preload_ready?
36
+ include_subdomains? && preload? && max_age >= 10_886_400
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ valid: valid?,
42
+ max_age:,
43
+ include_subdomains: include_subdomains?,
44
+ preload: preload?,
45
+ enabled: enabled?,
46
+ preload_ready: preload_ready?,
47
+ preload_list_status: preload_list['status']
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def headers
54
+ endpoint.headers
55
+ end
56
+
57
+ def header
58
+ @header ||= [headers['strict-transport-security']].flatten.join('; ').squeeze(';')
59
+ end
60
+
61
+ def directives
62
+ @directives ||= header ? header.split(/\s*;\s*/) : []
63
+ end
64
+
65
+ def pairs
66
+ @pairs ||= begin
67
+ pairs = {}
68
+ directives.each do |directive|
69
+ key, value = directive.downcase.split('=')
70
+
71
+ if /".*"/.match?(value)
72
+ value = value.sub(/^"/, '')
73
+ value = value.sub(/"$/, '')
74
+ end
75
+
76
+ pairs[key.to_sym] = value
77
+ end
78
+
79
+ pairs
80
+ end
81
+ end
82
+
83
+ def request
84
+ @request ||= begin
85
+ options = SiteInspector.typhoeus_defaults
86
+ options = options.merge(method: :get)
87
+ Typhoeus::Request.new(url, options)
88
+ end
89
+ end
90
+
91
+ def url
92
+ url = Addressable::URI.parse(STATUS_ENDPOINT)
93
+ url.query_values = { domain: endpoint.host.to_s }
94
+ url
95
+ end
96
+
97
+ def preload_list
98
+ @preload_list ||= begin
99
+ SiteInspector.hydra.queue(request)
100
+ SiteInspector.hydra.run
101
+
102
+ response = request.response
103
+ if response.success?
104
+ JSON.parse(response.body)
105
+ else
106
+ {}
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class SiteInspector
2
4
  class Endpoint
3
5
  class Https < Check
@@ -23,7 +25,7 @@ class SiteInspector
23
25
 
24
26
  def to_h
25
27
  {
26
- valid: valid?,
28
+ valid: valid?,
27
29
  return_code: response.return_code
28
30
  }
29
31
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SiteInspector
4
+ class Endpoint
5
+ class Wappalyzer < Check
6
+ class WappalyzerError < RuntimeError; end
7
+
8
+ class << self
9
+ def wappalyzer?
10
+ return @wappalyzer_detected if defined? @wappalyzer_detected
11
+
12
+ @wappalyzer_detected = !!wappalyzer.detect
13
+ end
14
+
15
+ def enabled?
16
+ @@enabled && wappalyzer?
17
+ end
18
+
19
+ def wappalyzer
20
+ @wappalyzer ||= begin
21
+ path = ['*', './bin', './node_modules/.bin'].join(File::PATH_SEPARATOR)
22
+ Cliver::Dependency.new('wappalyzer', path:)
23
+ end
24
+ end
25
+
26
+ def run_command(args)
27
+ Open3.capture2e(wappalyzer.detect, *args)
28
+ end
29
+ end
30
+
31
+ def to_h
32
+ return {} unless endpoint.up?
33
+ return {} if endpoint.redirect?
34
+
35
+ @to_h ||= begin
36
+ technologies = {}
37
+
38
+ data['technologies']&.each do |t|
39
+ category = t['categories'].first
40
+ category = category ? category['name'] : 'other'
41
+ category = category.downcase.tr(' ', '_').to_sym
42
+ technologies[category] ||= []
43
+ technologies[category].push t['name']
44
+ end
45
+
46
+ technologies
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def data
53
+ @data ||= begin
54
+ args = [endpoint.uri.to_s]
55
+ output, _status = self.class.run_command(args)
56
+ JSON.parse(output)
57
+ rescue Timeout::Error, IOError
58
+ { technologies: {
59
+ categories: ['timeout'],
60
+ name: "https://www.wappalyzer.com/lookup/#{endpoint.host}"
61
+ } }
62
+ rescue JSON::ParserError
63
+ raise WappalyzerError, "Command `wappalyzer #{args.join(' ')}` failed: #{output}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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