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
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class Endpoint
5
- class Content < Check
6
- # Given a path (e.g, "/data"), check if the given path exists on the canonical endpoint
7
- def path_exists?(path)
8
- endpoint.up? && endpoint.request(path: path, followlocation: true).success?
9
- end
10
-
11
- # The default Check#response method is from a HEAD request
12
- # The content check has a special response which includes the body from a GET request
13
- def response
14
- @response ||= endpoint.request(method: :get)
15
- end
16
-
17
- def document
18
- require 'nokogiri'
19
- @doc ||= Nokogiri::HTML response.body if response
20
- end
21
- alias doc document
22
-
23
- def body
24
- @body ||= document.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, replace: '')
25
- end
26
-
27
- def robots_txt?
28
- @bodts_txt ||= path_exists?('robots.txt') if proper_404s?
29
- end
30
-
31
- def sitemap_xml?
32
- @sitemap_xml ||= path_exists?('sitemap.xml') if proper_404s?
33
- end
34
-
35
- def humans_txt?
36
- @humans_txt ||= path_exists?('humans.txt') if proper_404s?
37
- end
38
-
39
- def doctype
40
- document.internal_subset.external_id
41
- end
42
-
43
- def generator
44
- @generator ||= begin
45
- tag = document.at('meta[name="generator"]')
46
- tag['content'] if tag
47
- end
48
- end
49
-
50
- def prefetch
51
- return unless endpoint.up?
52
-
53
- options = SiteInspector.typhoeus_defaults.merge(followlocation: true)
54
- ['robots.txt', 'sitemap.xml', 'humans.txt', random_path].each do |path|
55
- request = Typhoeus::Request.new(URI.join(endpoint.uri, path), options)
56
- SiteInspector.hydra.queue(request)
57
- end
58
- SiteInspector.hydra.run
59
- end
60
-
61
- def proper_404s?
62
- @proper_404s ||= !path_exists?(random_path)
63
- end
64
-
65
- def to_h
66
- prefetch
67
- {
68
- doctype: doctype,
69
- generator: generator,
70
- sitemap_xml: sitemap_xml?,
71
- robots_txt: robots_txt?,
72
- humans_txt: humans_txt?,
73
- proper_404s: proper_404s?
74
- }
75
- end
76
-
77
- private
78
-
79
- def random_path
80
- require 'securerandom'
81
- @random_path ||= SecureRandom.hex
82
- end
83
- end
84
- end
85
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class Endpoint
5
- class Headers < Check
6
- # TODO: kill this
7
- def strict_transport_security?
8
- !!strict_transport_security
9
- end
10
-
11
- def content_security_policy?
12
- !!content_security_policy
13
- end
14
-
15
- def click_jacking_protection?
16
- !!click_jacking_protection
17
- end
18
-
19
- # return the found header value
20
-
21
- # TODO: kill this
22
- def strict_transport_security
23
- headers['strict-transport-security']
24
- end
25
-
26
- def content_security_policy
27
- headers['content-security-policy']
28
- end
29
-
30
- def click_jacking_protection
31
- headers['x-frame-options']
32
- end
33
-
34
- def server
35
- headers['server']
36
- end
37
-
38
- def xss_protection
39
- headers['x-xss-protection']
40
- end
41
-
42
- # more specific checks than presence of headers
43
- def xss_protection?
44
- xss_protection == '1; mode=block'
45
- end
46
-
47
- # Returns an array of hashes of downcased key/value header pairs (or an empty hash)
48
- def all
49
- @all ||= response&.headers ? response.headers.transform_keys(&:downcase) : {}
50
- end
51
- alias headers all
52
-
53
- def [](header)
54
- headers[header]
55
- end
56
-
57
- def to_h
58
- {
59
- strict_transport_security: strict_transport_security || false,
60
- content_security_policy: content_security_policy || false,
61
- click_jacking_protection: click_jacking_protection || false,
62
- server: server,
63
- xss_protection: xss_protection || false
64
- }
65
- end
66
- end
67
- end
68
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class Endpoint
5
- class Sniffer < Check
6
- OPEN_SOURCE_FRAMEWORKS = [
7
- # Sniffles
8
- :drupal,
9
- :joomla,
10
- :movabletype,
11
- :phpbb,
12
- :wordpress,
13
-
14
- # Internal
15
- :php,
16
- :expression_engine,
17
- :cowboy
18
- ].freeze
19
-
20
- def framework
21
- cms = sniff :cms
22
- return cms unless cms.nil?
23
- return :expression_engine if endpoint.cookies.any? { |c| c.keys.first =~ /^exp_/ }
24
- return :php if endpoint.cookies['PHPSESSID']
25
- return :coldfusion if endpoint.cookies['CFID'] && endpoint.cookies['CFTOKEN']
26
- return :cowboy if endpoint.headers.server.to_s.casecmp('cowboy').zero?
27
-
28
- nil
29
- end
30
-
31
- def open_source?
32
- OPEN_SOURCE_FRAMEWORKS.include?(framework)
33
- end
34
-
35
- def analytics
36
- sniff :analytics
37
- end
38
-
39
- def javascript
40
- sniff :javascript
41
- end
42
-
43
- def advertising
44
- sniff :advertising
45
- end
46
-
47
- def to_h
48
- {
49
- framework: framework,
50
- analytics: analytics,
51
- javascript: javascript,
52
- advertising: advertising
53
- }
54
- end
55
-
56
- private
57
-
58
- def sniff(type)
59
- require 'sniffles'
60
- results = Sniffles.sniff(endpoint.content.body, type).select { |_name, meta| meta[:found] }
61
- results&.keys&.first
62
- rescue StandardError
63
- nil
64
- end
65
- end
66
- end
67
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class Endpoint
5
- class Wappalyzer < Check
6
- ENDPOINT = 'https://api.wappalyzer.com/lookup/v2/'
7
-
8
- def to_h
9
- return {} unless data['technologies']
10
-
11
- @to_h ||= begin
12
- technologies = {}
13
- data['technologies'].each do |t|
14
- category = t['categories'].first
15
- category = category ? category['name'] : 'Other'
16
- technologies[category] ||= []
17
- technologies[category].push t['name']
18
- end
19
-
20
- technologies
21
- end
22
- end
23
-
24
- private
25
-
26
- def request
27
- @request ||= begin
28
- options = SiteInspector.typhoeus_defaults
29
- headers = options[:headers].merge({ "x-api-key": api_key })
30
- options = options.merge(method: :get, headers: headers)
31
- Typhoeus::Request.new(url, options)
32
- end
33
- end
34
-
35
- def data
36
- return {} unless api_key && api_key != ''
37
-
38
- @data ||= begin
39
- SiteInspector.hydra.queue(request)
40
- SiteInspector.hydra.run
41
-
42
- response = request.response
43
- if response.success?
44
- JSON.parse(response.body).first
45
- else
46
- {}
47
- end
48
- end
49
- end
50
-
51
- def url
52
- url = Addressable::URI.parse(ENDPOINT)
53
- url.query_values = { urls: endpoint.uri }
54
- url
55
- end
56
-
57
- def api_key
58
- @api_key ||= ENV['WAPPALYZER_API_KEY']
59
- end
60
- end
61
- end
62
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class Endpoint
5
- class Whois < Check
6
- def domain
7
- @domain ||= whois.lookup host
8
- end
9
-
10
- def ip
11
- @ip ||= whois.lookup ip_address
12
- end
13
-
14
- def to_h
15
- {
16
- domain: record_to_h(domain),
17
- ip: record_to_h(ip)
18
- }
19
- end
20
-
21
- private
22
-
23
- def record_to_h(record)
24
- record.content.scan(/^\s*(.*?):\s*(.*?)\r?\n/).to_h
25
- end
26
-
27
- def ip_address
28
- @ip_address ||= Resolv.getaddress host
29
- end
30
-
31
- def whois
32
- @whois ||= ::Whois::Client.new
33
- end
34
- end
35
- end
36
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class DiskCache
5
- def initialize(dir = nil, replace = nil)
6
- @dir = dir || ENV['CACHE']
7
- @replace = replace || ENV['CACHE_REPLACE']
8
- @memory = {}
9
- end
10
-
11
- def get(request)
12
- return unless File.exist?(path(request))
13
- return @memory[request] if @memory[request]
14
-
15
- if @replace
16
- FileUtils.rm(path(request))
17
- nil
18
- else
19
- begin
20
- contents = File.read(path(request))
21
- Marshal.load(contents)
22
- rescue ArgumentError
23
- FileUtils.rm(path(request))
24
- nil
25
- end
26
- end
27
- end
28
-
29
- def set(request, response)
30
- File.write(path(request), Marshal.dump(response))
31
- @memory[request] = response
32
- end
33
-
34
- private
35
-
36
- # The `request` is a Typhoeus::Request, which provides a
37
- # unique `cache_key` string for exactly this sort of thing.
38
- def path(request)
39
- File.join(@dir, request.cache_key)
40
- end
41
- end
42
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class SiteInspector
4
- class RailsCache
5
- def get(request)
6
- Rails.cache.read(request)
7
- end
8
-
9
- def set(request, response)
10
- Rails.cache.write(request, response)
11
- end
12
- end
13
- end
@@ -1,150 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe SiteInspector::Endpoint::Sniffer do
6
- def stub_header(header, value)
7
- allow(subject.endpoint.headers).to receive(:headers) { { header => value } }
8
- end
9
-
10
- def set_cookie(key, value)
11
- cookies = [
12
- CGI::Cookie.new(
13
- 'name' => 'foo',
14
- 'value' => 'bar',
15
- 'domain' => 'example.com',
16
- 'path' => '/'
17
- ),
18
- CGI::Cookie.new(
19
- 'name' => key,
20
- 'value' => value,
21
- 'domain' => 'example.com',
22
- 'path' => '/'
23
- )
24
- ].map(&:to_s)
25
-
26
- stub_request(:get, 'http://example.com/')
27
- .to_return(status: 200, body: '')
28
-
29
- stub_request(:head, 'http://example.com/')
30
- .to_return(status: 200, headers: { 'set-cookie' => cookies })
31
- end
32
-
33
- context 'stubbed body' do
34
- subject do
35
- body = <<-BODY
36
- <html>
37
- <head>
38
- <link rel='stylesheet' href='/wp-content/themes/foo/style.css type='text/css' media='all' />
39
- </head>
40
- <body>
41
- <h1>Some page</h1>
42
- <script>
43
- jQuery(); googletag.pubads();
44
- </script>
45
- <script>
46
- var _gaq=[['_setAccount','UA-12345678-1'],['_trackPageview']];
47
- (function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
48
- g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js';
49
- s.parentNode.insertBefore(g,s)}(document,'script'));
50
- </script>
51
- </body>
52
- </html>
53
- BODY
54
-
55
- stub_request(:get, 'http://example.com/')
56
- .to_return(status: 200, body: body)
57
-
58
- stub_request(:head, 'http://example.com/')
59
- .to_return(status: 200)
60
- endpoint = SiteInspector::Endpoint.new('http://example.com')
61
- described_class.new(endpoint)
62
- end
63
-
64
- it 'sniffs' do
65
- sniff = subject.send(:sniff, :cms)
66
- expect(sniff).to be(:wordpress)
67
- end
68
-
69
- it 'detects the CMS' do
70
- expect(subject.framework).to be(:wordpress)
71
- end
72
-
73
- it 'detects the analytics' do
74
- expect(subject.analytics).to be(:google_analytics)
75
- end
76
-
77
- it 'detects javascript' do
78
- expect(subject.javascript).to be(:jquery)
79
- end
80
-
81
- it 'detects advertising' do
82
- expect(subject.advertising).to be(:adsense)
83
- end
84
-
85
- it 'knows wordpress is open source' do
86
- expect(subject.open_source?).to be(true)
87
- end
88
- end
89
-
90
- context 'no body' do
91
- subject do
92
- endpoint = SiteInspector::Endpoint.new('http://example.com')
93
- described_class.new(endpoint)
94
- end
95
-
96
- it "knows when something isn't open source" do
97
- set_cookie('foo', 'bar')
98
- expect(subject.open_source?).to be(false)
99
- end
100
-
101
- it 'detects PHP' do
102
- set_cookie('PHPSESSID', '1234')
103
- expect(subject.framework).to be(:php)
104
- expect(subject.open_source?).to be(true)
105
- end
106
-
107
- it 'detects Expression Engine' do
108
- set_cookie('exp_csrf_token', '1234')
109
- expect(subject.framework).to be(:expression_engine)
110
- expect(subject.open_source?).to be(true)
111
- end
112
-
113
- it 'detects cowboy' do
114
- stub_request(:get, 'http://example.com/')
115
- .to_return(status: 200, body: '')
116
-
117
- stub_request(:head, 'http://example.com/')
118
- .to_return(status: 200, headers: { 'server' => 'Cowboy' })
119
-
120
- expect(subject.framework).to be(:cowboy)
121
- expect(subject.open_source?).to be(true)
122
- end
123
-
124
- it 'detects ColdFusion' do
125
- cookies = [
126
- CGI::Cookie.new(
127
- 'name' => 'CFID',
128
- 'value' => '1234',
129
- 'domain' => 'example.com',
130
- 'path' => '/'
131
- ),
132
- CGI::Cookie.new(
133
- 'name' => 'CFTOKEN',
134
- 'value' => '5678',
135
- 'domain' => 'example.com',
136
- 'path' => '/'
137
- )
138
- ].map(&:to_s)
139
-
140
- stub_request(:get, 'http://example.com/')
141
- .to_return(status: 200, body: '')
142
-
143
- stub_request(:head, 'http://example.com/')
144
- .to_return(status: 200, headers: { 'set-cookie' => cookies })
145
-
146
- expect(subject.framework).to be(:coldfusion)
147
- expect(subject.open_source?).to be(false)
148
- end
149
- end
150
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe SiteInspector::Endpoint::Wappalyzer do
6
- subject { described_class.new(endpoint) }
7
-
8
- let(:domain) { 'http://ben.balter.com.com' }
9
- let(:endpoint) { SiteInspector::Endpoint.new(domain) }
10
- let(:url) { "https://api.wappalyzer.com/lookup/v2/?urls=#{domain}/" }
11
-
12
- before do
13
- path = File.expand_path '../fixtures/wappalyzer.json', __dir__
14
- body = File.read path
15
- stub_request(:get, url).to_return(status: 200, body: body)
16
- end
17
-
18
- it 'returns the API response' do
19
- expected = {
20
- 'Analytics' => ['Google Analytics'],
21
- 'CDN' => %w[Cloudflare Fastly],
22
- 'Caching' => ['Varnish'],
23
- 'Other' => %w[Disqus Jekyll],
24
- 'PaaS' => ['GitHub Pages'],
25
- 'Web frameworks' => ['Ruby on Rails']
26
- }
27
- expect(subject.to_h).to eql(expected)
28
- end
29
-
30
- it 'fails gracefully' do
31
- stub_request(:get, url).to_return(status: 400, body: '')
32
- expect(subject.to_h).to eql({})
33
- end
34
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe SiteInspector::Cache do
6
- it 'stores a cache value' do
7
- subject.set 'foo', 'bar'
8
- expect(subject.instance_variable_get('@memory')['foo']).to eql('bar')
9
- end
10
-
11
- it 'retrieves values from the cache' do
12
- subject.instance_variable_set('@memory', 'foo' => 'bar')
13
- expect(subject.get('foo')).to eql('bar')
14
- end
15
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- describe SiteInspector::DiskCache do
6
- subject { described_class.new(tmpdir) }
7
-
8
- before do
9
- FileUtils.rm_rf(tmpdir)
10
- Dir.mkdir(tmpdir)
11
- end
12
-
13
- it 'writes a value to disk' do
14
- foo = Typhoeus::Request.new('foo')
15
-
16
- path = File.expand_path foo.cache_key, tmpdir
17
- expect(File.exist?(path)).to be(false)
18
-
19
- subject.set foo, 'bar'
20
-
21
- expect(File.exist?(path)).to be(true)
22
- expect(File.open(path).read).to eql("I\"bar:ET")
23
- end
24
-
25
- it 'reads a value from disk' do
26
- foo = Typhoeus::Request.new('foo')
27
-
28
- path = File.expand_path foo.cache_key, tmpdir
29
- File.write(path, "I\"bar:ET")
30
- expect(subject.get(foo)).to eql('bar')
31
- end
32
-
33
- it "calculates a file's path" do
34
- foo = Typhoeus::Request.new('foo')
35
-
36
- path = File.expand_path foo.cache_key, tmpdir
37
- expect(subject.send(:path, foo)).to eql(path)
38
- end
39
- end
File without changes