site-inspector 1.0.2 → 2.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.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +3 -0
- data/Guardfile +8 -0
- data/README.md +175 -0
- data/Rakefile +8 -0
- data/bin/site-inspector +48 -21
- data/lib/site-inspector.rb +38 -613
- data/lib/site-inspector/cache.rb +9 -52
- data/lib/site-inspector/checks/check.rb +41 -0
- data/lib/site-inspector/checks/content.rb +67 -0
- data/lib/site-inspector/checks/dns.rb +129 -0
- data/lib/site-inspector/checks/headers.rb +83 -0
- data/lib/site-inspector/checks/hsts.rb +78 -0
- data/lib/site-inspector/checks/https.rb +40 -0
- data/lib/site-inspector/checks/sniffer.rb +42 -0
- data/lib/site-inspector/disk_cache.rb +38 -0
- data/lib/site-inspector/domain.rb +248 -0
- data/lib/site-inspector/endpoint.rb +200 -0
- data/lib/site-inspector/rails_cache.rb +11 -0
- data/lib/site-inspector/version.rb +3 -0
- data/script/bootstrap +1 -0
- data/script/cibuild +7 -0
- data/script/console +1 -0
- data/script/release +38 -0
- data/site-inspector.gemspec +33 -0
- data/spec/checks/site_inspector_endpoint_check_spec.rb +34 -0
- data/spec/checks/site_inspector_endpoint_content_spec.rb +89 -0
- data/spec/checks/site_inspector_endpoint_dns_spec.rb +167 -0
- data/spec/checks/site_inspector_endpoint_headers_spec.rb +74 -0
- data/spec/checks/site_inspector_endpoint_hsts_spec.rb +91 -0
- data/spec/checks/site_inspector_endpoint_https_spec.rb +48 -0
- data/spec/checks/site_inspector_endpoint_sniffer_spec.rb +52 -0
- data/spec/site_inspector_cache_spec.rb +13 -0
- data/spec/site_inspector_disc_cache_spec.rb +31 -0
- data/spec/site_inspector_domain_spec.rb +252 -0
- data/spec/site_inspector_endpoint_spec.rb +224 -0
- data/spec/site_inspector_spec.rb +46 -0
- data/spec/spec_helper.rb +17 -0
- metadata +75 -57
- data/lib/site-inspector/compliance.rb +0 -19
- data/lib/site-inspector/dns.rb +0 -92
- data/lib/site-inspector/headers.rb +0 -59
- data/lib/site-inspector/sniffer.rb +0 -26
data/lib/site-inspector/cache.rb
CHANGED
@@ -1,58 +1,15 @@
|
|
1
|
-
class
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
def get(request)
|
7
|
-
@memory[request]
|
8
|
-
end
|
9
|
-
|
10
|
-
def set(request, response)
|
11
|
-
@memory[request] = response
|
12
|
-
end
|
13
|
-
end
|
14
|
-
|
15
|
-
class SiteInspectorDiskCache
|
16
|
-
def initialize(dir = nil, replace = false)
|
17
|
-
@dir = dir
|
18
|
-
@memory = {}
|
19
|
-
@replace = replace
|
20
|
-
end
|
21
|
-
|
22
|
-
def path(request)
|
23
|
-
File.join(@dir, request.cache_key)
|
24
|
-
end
|
25
|
-
|
26
|
-
def fetch(request)
|
27
|
-
if File.exist?(path(request))
|
28
|
-
|
29
|
-
if @replace
|
30
|
-
FileUtils.rm(path(request))
|
31
|
-
nil
|
32
|
-
else
|
33
|
-
contents = File.read(path(request))
|
34
|
-
begin
|
35
|
-
Marshal.load(contents)
|
36
|
-
rescue ArgumentError
|
37
|
-
FileUtils.rm(path(request))
|
38
|
-
nil
|
39
|
-
end
|
40
|
-
end
|
1
|
+
class SiteInspector
|
2
|
+
class Cache
|
3
|
+
def memory
|
4
|
+
@memory ||= {}
|
41
5
|
end
|
42
|
-
end
|
43
6
|
|
44
|
-
|
45
|
-
|
46
|
-
f.write Marshal.dump(response)
|
7
|
+
def get(request)
|
8
|
+
memory[request]
|
47
9
|
end
|
48
|
-
end
|
49
|
-
|
50
|
-
def get(request)
|
51
|
-
@memory[request] || fetch(request)
|
52
|
-
end
|
53
10
|
|
54
|
-
|
55
|
-
|
56
|
-
|
11
|
+
def set(request, response)
|
12
|
+
memory[request] = response
|
13
|
+
end
|
57
14
|
end
|
58
15
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class SiteInspector
|
2
|
+
class Endpoint
|
3
|
+
class Check
|
4
|
+
|
5
|
+
attr_reader :endpoint
|
6
|
+
|
7
|
+
# A check is an abstract class that takes an Endpoint object
|
8
|
+
# and is extended to preform the specific site inspector checks
|
9
|
+
#
|
10
|
+
# It is automatically accessable within the endpoint object
|
11
|
+
# by virtue of extending the Check class
|
12
|
+
def initialize(endpoint)
|
13
|
+
@endpoint = endpoint
|
14
|
+
end
|
15
|
+
|
16
|
+
def response
|
17
|
+
endpoint.response
|
18
|
+
end
|
19
|
+
|
20
|
+
def request
|
21
|
+
response.request
|
22
|
+
end
|
23
|
+
|
24
|
+
def host
|
25
|
+
request.base_url.host
|
26
|
+
end
|
27
|
+
|
28
|
+
def inspect
|
29
|
+
"#<#{self.class} endpoint=\"#{response.effective_url}\">"
|
30
|
+
end
|
31
|
+
|
32
|
+
def name
|
33
|
+
self.class.name
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.name
|
37
|
+
self.to_s.split('::').last.downcase.to_sym
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
class SiteInspector
|
2
|
+
class Endpoint
|
3
|
+
class Content < Check
|
4
|
+
# Given a path (e.g, "/data"), check if the given path exists on the canonical endpoint
|
5
|
+
def path_exists?(path)
|
6
|
+
endpoint.request(path: path, followlocation: true).success?
|
7
|
+
end
|
8
|
+
|
9
|
+
def document
|
10
|
+
require 'nokogiri'
|
11
|
+
@doc ||= Nokogiri::HTML response.body if response
|
12
|
+
end
|
13
|
+
alias_method :doc, :document
|
14
|
+
|
15
|
+
def body
|
16
|
+
@body ||= document.to_s.force_encoding("UTF-8").encode("UTF-8", :invalid => :replace, :replace => "")
|
17
|
+
end
|
18
|
+
|
19
|
+
def robots_txt?
|
20
|
+
@bodts_txt ||= path_exists?("robots.txt")
|
21
|
+
end
|
22
|
+
|
23
|
+
def sitemap_xml?
|
24
|
+
@sitemap_xml ||= path_exists?("sitemap.xml")
|
25
|
+
end
|
26
|
+
|
27
|
+
def humans_txt?
|
28
|
+
@humans_txt ||= path_exists?("humans.txt")
|
29
|
+
end
|
30
|
+
|
31
|
+
def doctype
|
32
|
+
document.internal_subset.name
|
33
|
+
end
|
34
|
+
|
35
|
+
def prefetch
|
36
|
+
options = SiteInspector.typhoeus_defaults.merge(followlocation: true)
|
37
|
+
["robots.txt", "sitemap.xml", "humans.txt", random_path].each do |path|
|
38
|
+
request = Typhoeus::Request.new(URI.join(endpoint.uri, path), options)
|
39
|
+
SiteInspector.hydra.queue(request)
|
40
|
+
end
|
41
|
+
SiteInspector.hydra.run
|
42
|
+
end
|
43
|
+
|
44
|
+
def proper_404s?
|
45
|
+
@proper_404s ||= !path_exists?(random_path)
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_h
|
49
|
+
prefetch
|
50
|
+
{
|
51
|
+
doctype: doctype,
|
52
|
+
sitemap_xml: sitemap_xml?,
|
53
|
+
robots_txt: robots_txt?,
|
54
|
+
humans_txt: humans_txt?,
|
55
|
+
proper_404s: proper_404s?
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def random_path
|
62
|
+
require 'securerandom'
|
63
|
+
@random_path ||= SecureRandom.hex
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
class SiteInspector
|
2
|
+
class Endpoint
|
3
|
+
class Dns < Check
|
4
|
+
|
5
|
+
def self.resolver
|
6
|
+
require "dnsruby"
|
7
|
+
@resolver ||= begin
|
8
|
+
resolver = Dnsruby::Resolver.new
|
9
|
+
resolver.config.nameserver = ["8.8.8.8", "8.8.4.4"]
|
10
|
+
resolver
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def query(type="ANY")
|
15
|
+
SiteInspector::Endpoint::Dns.resolver.query(host.to_s, type).answer
|
16
|
+
rescue Dnsruby::ResolvTimeout, Dnsruby::ServFail, Dnsruby::NXDomain
|
17
|
+
[]
|
18
|
+
end
|
19
|
+
|
20
|
+
def records
|
21
|
+
@records ||= query
|
22
|
+
end
|
23
|
+
|
24
|
+
def has_record?(type)
|
25
|
+
records.any? { |record| record.type == type } || query(type).count != 0
|
26
|
+
end
|
27
|
+
|
28
|
+
def dnssec?
|
29
|
+
@dnssec ||= has_record? "DNSKEY"
|
30
|
+
end
|
31
|
+
|
32
|
+
def ipv6?
|
33
|
+
@ipv6 ||= has_record? "AAAA"
|
34
|
+
end
|
35
|
+
|
36
|
+
def cdn
|
37
|
+
detect_by_hostname "cdn"
|
38
|
+
end
|
39
|
+
|
40
|
+
def cdn?
|
41
|
+
!!cdn
|
42
|
+
end
|
43
|
+
|
44
|
+
def cloud_provider
|
45
|
+
detect_by_hostname "cloud"
|
46
|
+
end
|
47
|
+
|
48
|
+
def cloud?
|
49
|
+
!!cloud_provider
|
50
|
+
end
|
51
|
+
|
52
|
+
def google_apps?
|
53
|
+
@google ||= records.any? do |record|
|
54
|
+
record.type == "MX" && record.exchange.to_s =~ /google(mail)?\.com\.?$/
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def ip
|
59
|
+
require 'resolv'
|
60
|
+
@ip ||= Resolv.getaddress host
|
61
|
+
rescue Resolv::ResolvError
|
62
|
+
nil
|
63
|
+
end
|
64
|
+
|
65
|
+
def hostname
|
66
|
+
require 'resolv'
|
67
|
+
@hostname ||= PublicSuffix.parse(Resolv.getname(ip))
|
68
|
+
rescue Resolv::ResolvError, PublicSuffix::DomainInvalid
|
69
|
+
nil
|
70
|
+
end
|
71
|
+
|
72
|
+
def cnames
|
73
|
+
@cnames ||= records.select { |record| record.type == "CNAME" }.map do |record|
|
74
|
+
PublicSuffix.parse(record.cname.to_s)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def inspect
|
79
|
+
"#<SiteInspector::Domain::Dns host=\"#{host}\">"
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_h
|
83
|
+
{
|
84
|
+
:dnssec => dnssec?,
|
85
|
+
:ipv6 => ipv6?,
|
86
|
+
:cdn => cdn,
|
87
|
+
:cloud_provider => cloud_provider,
|
88
|
+
:google_apps => google_apps?,
|
89
|
+
:hostname => hostname,
|
90
|
+
:ip => ip
|
91
|
+
}
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def data
|
97
|
+
@data ||= {}
|
98
|
+
end
|
99
|
+
|
100
|
+
def data_path(name)
|
101
|
+
File.expand_path "../../data/#{name}.yml", File.dirname(__FILE__)
|
102
|
+
end
|
103
|
+
|
104
|
+
def load_data(name)
|
105
|
+
require 'yaml'
|
106
|
+
path = data_path(name)
|
107
|
+
data[name] ||= YAML.load_file(path)
|
108
|
+
end
|
109
|
+
|
110
|
+
def detect_by_hostname(type)
|
111
|
+
haystack = load_data(type)
|
112
|
+
needle = haystack.find do |name, domain|
|
113
|
+
cnames.any? do |cname|
|
114
|
+
domain == cname.tld || domain == "#{cname.sld}.#{cname.tld}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
return needle[0].to_sym if needle
|
119
|
+
return nil unless hostname
|
120
|
+
|
121
|
+
needle = haystack.find do |name, domain|
|
122
|
+
domain == hostname.tld || domain == "#{hostname.sld}.#{hostname.tld}"
|
123
|
+
end
|
124
|
+
|
125
|
+
needle ? needle[0].to_sym : nil
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
class SiteInspector
|
2
|
+
class Endpoint
|
3
|
+
class Headers < Check
|
4
|
+
|
5
|
+
# cookies can have multiple set-cookie headers, so this detects
|
6
|
+
# whether cookies are set, but not all their values.
|
7
|
+
def cookies?
|
8
|
+
!!headers["set-cookie"]
|
9
|
+
end
|
10
|
+
|
11
|
+
# TODO: kill this
|
12
|
+
def strict_transport_security?
|
13
|
+
!!strict_transport_security
|
14
|
+
end
|
15
|
+
|
16
|
+
def content_security_policy?
|
17
|
+
!!content_security_policy
|
18
|
+
end
|
19
|
+
|
20
|
+
def click_jacking_protection?
|
21
|
+
!!click_jacking_protection
|
22
|
+
end
|
23
|
+
|
24
|
+
# return the found header value
|
25
|
+
|
26
|
+
# TODO: kill this
|
27
|
+
def strict_transport_security
|
28
|
+
headers["strict-transport-security"]
|
29
|
+
end
|
30
|
+
|
31
|
+
def content_security_policy
|
32
|
+
headers["content-security-policy"]
|
33
|
+
end
|
34
|
+
|
35
|
+
def click_jacking_protection
|
36
|
+
headers["x-frame-options"]
|
37
|
+
end
|
38
|
+
|
39
|
+
def server
|
40
|
+
headers["server"]
|
41
|
+
end
|
42
|
+
|
43
|
+
def xss_protection
|
44
|
+
headers["x-xss-protection"]
|
45
|
+
end
|
46
|
+
|
47
|
+
# more specific checks than presence of headers
|
48
|
+
def xss_protection?
|
49
|
+
xss_protection == "1; mode=block"
|
50
|
+
end
|
51
|
+
|
52
|
+
def secure_cookies?
|
53
|
+
return false if !cookies?
|
54
|
+
cookie = headers["set-cookie"]
|
55
|
+
cookie = cookie.first if cookie.is_a?(Array)
|
56
|
+
!!(cookie =~ /(; secure.*; httponly|; httponly.*; secure)/i)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns an array of hashes of downcased key/value header pairs (or an empty hash)
|
60
|
+
def all
|
61
|
+
@all ||= (response && response.headers) ? Hash[response.headers.map{ |k,v| [k.downcase,v] }] : {}
|
62
|
+
end
|
63
|
+
alias_method :headers, :all
|
64
|
+
|
65
|
+
def [](header)
|
66
|
+
headers[header]
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_h
|
70
|
+
{
|
71
|
+
:cookies => cookies?,
|
72
|
+
:strict_transport_security => strict_transport_security || false,
|
73
|
+
:content_security_policy => content_security_policy || false,
|
74
|
+
:click_jacking_protection => click_jacking_protection || false,
|
75
|
+
:click_jacking_protection => click_jacking_protection || false,
|
76
|
+
:server => server,
|
77
|
+
:xss_protection => xss_protection || false,
|
78
|
+
:secure_cookies => secure_cookies?
|
79
|
+
}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
class SiteInspector
|
2
|
+
class Endpoint
|
3
|
+
# Utility parser for HSTS headers.
|
4
|
+
# RFC: http://tools.ietf.org/html/rfc6797
|
5
|
+
class Hsts < Check
|
6
|
+
|
7
|
+
def valid?
|
8
|
+
return false unless header
|
9
|
+
pairs.none? { |key, value| "#{key}#{value}" =~ /[\s\'\"]/ }
|
10
|
+
end
|
11
|
+
|
12
|
+
def max_age
|
13
|
+
pairs[:"max-age"].to_i
|
14
|
+
end
|
15
|
+
|
16
|
+
def include_subdomains?
|
17
|
+
pairs.keys.include? :includesubdomains
|
18
|
+
end
|
19
|
+
|
20
|
+
def preload?
|
21
|
+
pairs.keys.include? :preload
|
22
|
+
end
|
23
|
+
|
24
|
+
def enabled?
|
25
|
+
return false unless max_age
|
26
|
+
max_age > 0
|
27
|
+
end
|
28
|
+
|
29
|
+
# Google's minimum max-age for automatic preloading
|
30
|
+
def preload_ready?
|
31
|
+
include_subdomains? and preload? and max_age >= 10886400
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_h
|
35
|
+
{
|
36
|
+
valid: valid?,
|
37
|
+
max_age: max_age,
|
38
|
+
include_subdomains: include_subdomains?,
|
39
|
+
preload: preload?,
|
40
|
+
enabled: enabled?,
|
41
|
+
preload_ready: preload_ready?
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def headers
|
48
|
+
endpoint.headers
|
49
|
+
end
|
50
|
+
|
51
|
+
def header
|
52
|
+
@header ||= headers["strict-transport-security"]
|
53
|
+
end
|
54
|
+
|
55
|
+
def directives
|
56
|
+
@directives ||= header ? header.split(/\s*;\s*/) : []
|
57
|
+
end
|
58
|
+
|
59
|
+
def pairs
|
60
|
+
@pairs ||= begin
|
61
|
+
pairs = {}
|
62
|
+
directives.each do |directive|
|
63
|
+
key, value = directive.downcase.split("=")
|
64
|
+
|
65
|
+
if value =~ /\".*\"/
|
66
|
+
value = value.sub(/^\"/, '')
|
67
|
+
value = value.sub(/\"$/, '')
|
68
|
+
end
|
69
|
+
|
70
|
+
pairs[key.to_sym] = value
|
71
|
+
end
|
72
|
+
|
73
|
+
pairs
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|