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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +30 -0
- data/.gitignore +1 -1
- data/.rubocop.yml +14 -10
- data/.rubocop_todo.yml +245 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +71 -0
- data/Gemfile +2 -0
- data/Guardfile +2 -0
- data/README.md +8 -14
- data/Rakefile +2 -0
- data/bin/site-inspector +16 -15
- data/lib/cliver/dependency_ext.rb +8 -5
- data/lib/data/well-known.yml +51 -0
- data/lib/site-inspector/domain.rb +61 -43
- data/lib/site-inspector/domain_parser.rb +11 -0
- data/lib/site-inspector/{checks → endpoint}/accessibility.rb +35 -25
- data/lib/site-inspector/{checks → endpoint}/check.rb +9 -3
- data/lib/site-inspector/endpoint/content.rb +151 -0
- data/lib/site-inspector/endpoint/cookies.rb +53 -0
- data/lib/site-inspector/{checks → endpoint}/dns.rb +52 -26
- data/lib/site-inspector/endpoint/headers.rb +55 -0
- data/lib/site-inspector/endpoint/hsts.rb +112 -0
- data/lib/site-inspector/{checks → endpoint}/https.rb +3 -1
- data/lib/site-inspector/endpoint/wappalyzer.rb +68 -0
- data/lib/site-inspector/endpoint/well_known.rb +95 -0
- data/lib/site-inspector/endpoint/whois.rb +56 -0
- data/lib/site-inspector/endpoint.rb +80 -44
- data/lib/site-inspector/formatter.rb +118 -0
- data/lib/site-inspector/version.rb +3 -1
- data/lib/site-inspector.rb +42 -31
- data/package-lock.json +1152 -0
- data/package.json +2 -1
- data/renovate.json +4 -0
- data/script/benchmark +8 -0
- data/script/cibuild +2 -2
- data/script/console +1 -1
- data/script/pa11y-version +2 -1
- data/script/release +11 -4
- data/script/update-well-known +18 -0
- data/site-inspector.gemspec +33 -19
- data/spec/cliver/dependency_ext_spec.rb +21 -0
- data/spec/fixtures/wappalyzer.json +125 -0
- data/spec/{site_inspector_domain_spec.rb → site_inspector/domain_spec.rb} +105 -43
- data/spec/{checks/site_inspector_endpoint_accessibility_spec.rb → site_inspector/endpoint/accessibility_spec.rb} +24 -20
- data/spec/{checks/site_inspector_endpoint_check_spec.rb → site_inspector/endpoint/check_spec.rb} +9 -7
- data/spec/{checks/site_inspector_endpoint_content_spec.rb → site_inspector/endpoint/content_spec.rb} +43 -24
- data/spec/{checks/site_inspector_endpoint_cookies_spec.rb → site_inspector/endpoint/cookies_spec.rb} +37 -22
- data/spec/site_inspector/endpoint/dns_spec.rb +262 -0
- data/spec/{checks/site_inspector_endpoint_headers_spec.rb → site_inspector/endpoint/headers_spec.rb} +10 -15
- data/spec/site_inspector/endpoint/hsts_spec.rb +92 -0
- data/spec/{checks/site_inspector_endpoint_https_spec.rb → site_inspector/endpoint/https_spec.rb} +12 -10
- data/spec/site_inspector/endpoint/wappalyzer_spec.rb +20 -0
- data/spec/site_inspector/endpoint/well_known_spec.rb +12 -0
- data/spec/site_inspector/endpoint/whois_spec.rb +27 -0
- data/spec/{site_inspector_endpoint_spec.rb → site_inspector/endpoint_spec.rb} +102 -45
- data/spec/site_inspector_spec.rb +67 -18
- data/spec/spec_helper.rb +11 -2
- metadata +178 -100
- data/.travis.yml +0 -9
- data/lib/site-inspector/cache.rb +0 -15
- data/lib/site-inspector/checks/content.rb +0 -74
- data/lib/site-inspector/checks/cookies.rb +0 -43
- data/lib/site-inspector/checks/headers.rb +0 -66
- data/lib/site-inspector/checks/hsts.rb +0 -77
- data/lib/site-inspector/checks/sniffer.rb +0 -64
- data/lib/site-inspector/disk_cache.rb +0 -40
- data/lib/site-inspector/rails_cache.rb +0 -11
- data/spec/checks/site_inspector_endpoint_dns_spec.rb +0 -182
- data/spec/checks/site_inspector_endpoint_hsts_spec.rb +0 -90
- data/spec/checks/site_inspector_endpoint_sniffer_spec.rb +0 -148
- data/spec/site_inspector_cache_spec.rb +0 -13
- data/spec/site_inspector_disk_cache_spec.rb +0 -37
|
@@ -1,22 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class SiteInspector
|
|
2
4
|
class Domain
|
|
3
5
|
attr_reader :host
|
|
4
6
|
|
|
5
7
|
def initialize(host)
|
|
6
|
-
host = host
|
|
7
|
-
host = host
|
|
8
|
-
host = host.sub(%r{^/+}, '')
|
|
9
|
-
host = host.sub(/^www\./, '')
|
|
10
|
-
uri = Addressable::URI.parse "//#{host}"
|
|
11
|
-
@host = uri.host
|
|
8
|
+
@host = DomainParser.parse(host)
|
|
9
|
+
@host = strip_www(@host) if @host&.trd
|
|
12
10
|
end
|
|
13
11
|
|
|
14
12
|
def endpoints
|
|
15
13
|
@endpoints ||= [
|
|
16
|
-
Endpoint.
|
|
17
|
-
Endpoint.
|
|
18
|
-
Endpoint.
|
|
19
|
-
Endpoint.
|
|
14
|
+
Endpoint.build(self, https: true, www: false),
|
|
15
|
+
Endpoint.build(self, https: true, www: true),
|
|
16
|
+
Endpoint.build(self, https: false, www: false),
|
|
17
|
+
Endpoint.build(self, https: false, www: true)
|
|
20
18
|
]
|
|
21
19
|
end
|
|
22
20
|
|
|
@@ -82,7 +80,8 @@ class SiteInspector
|
|
|
82
80
|
# TODO: don't need to require that the HTTPS cert is valid for this purpose.
|
|
83
81
|
def enforces_https?
|
|
84
82
|
return false unless https?
|
|
85
|
-
|
|
83
|
+
|
|
84
|
+
endpoints.select(&:http?).all? { |e| !e.up? || e.redirect&.https? }
|
|
86
85
|
end
|
|
87
86
|
|
|
88
87
|
# we can say that a canonical HTTPS site "defaults" to HTTPS,
|
|
@@ -91,18 +90,20 @@ class SiteInspector
|
|
|
91
90
|
#
|
|
92
91
|
# TODO: not implemented.
|
|
93
92
|
def defaults_https?
|
|
94
|
-
|
|
93
|
+
raise 'Not implemented. Halp?'
|
|
95
94
|
end
|
|
96
95
|
|
|
97
96
|
# HTTPS is "downgraded" if both:
|
|
98
97
|
#
|
|
99
98
|
# * HTTPS is supported, and
|
|
100
|
-
# *
|
|
99
|
+
# * Any HTTPS endpoint redirects to HTTP.
|
|
101
100
|
#
|
|
102
|
-
#
|
|
101
|
+
# This correctly identifies domains where HTTPS is available but
|
|
102
|
+
# redirects to HTTP, regardless of which endpoint is canonical.
|
|
103
103
|
def downgrades_https?
|
|
104
104
|
return false unless https?
|
|
105
|
-
|
|
105
|
+
|
|
106
|
+
endpoints.select(&:https?).any? { |e| e.redirect&.http? }
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
# A domain is "canonically" at www if:
|
|
@@ -129,7 +130,7 @@ class SiteInspector
|
|
|
129
130
|
return true if endpoints.select(&:root?).all? { |e| !e.up? }
|
|
130
131
|
|
|
131
132
|
# Does either root endpoint redirect to a www endpoint?
|
|
132
|
-
endpoints.select(&:root?).any? { |e| e.redirect
|
|
133
|
+
endpoints.select(&:root?).any? { |e| e.redirect&.www? }
|
|
133
134
|
end
|
|
134
135
|
|
|
135
136
|
# A domain is "canonically" at https if:
|
|
@@ -160,7 +161,7 @@ class SiteInspector
|
|
|
160
161
|
return true if endpoints.select(&:http?).all? { |e| !e.up? }
|
|
161
162
|
|
|
162
163
|
# at least one http endpoint redirects immediately to https
|
|
163
|
-
endpoints.select(&:http?).any? { |e| e.redirect
|
|
164
|
+
endpoints.select(&:http?).any? { |e| e.redirect&.https? }
|
|
164
165
|
end
|
|
165
166
|
|
|
166
167
|
# A domain redirects if
|
|
@@ -168,6 +169,7 @@ class SiteInspector
|
|
|
168
169
|
# 2. All endpoints are either down or an external redirect
|
|
169
170
|
def redirect?
|
|
170
171
|
return false unless redirect
|
|
172
|
+
|
|
171
173
|
endpoints.all? { |e| !e.up? || e.external_redirect? }
|
|
172
174
|
end
|
|
173
175
|
|
|
@@ -178,7 +180,7 @@ class SiteInspector
|
|
|
178
180
|
|
|
179
181
|
# HSTS on the canonical domain?
|
|
180
182
|
def hsts?
|
|
181
|
-
canonical_endpoint.hsts
|
|
183
|
+
canonical_endpoint.hsts&.enabled?
|
|
182
184
|
end
|
|
183
185
|
|
|
184
186
|
def hsts_subdomains?
|
|
@@ -187,11 +189,12 @@ class SiteInspector
|
|
|
187
189
|
|
|
188
190
|
def hsts_preload_ready?
|
|
189
191
|
return false unless hsts_subdomains?
|
|
192
|
+
|
|
190
193
|
endpoints.find { |e| e.root? && e.https? }.hsts.preload_ready?
|
|
191
194
|
end
|
|
192
195
|
|
|
193
196
|
def to_s
|
|
194
|
-
host
|
|
197
|
+
host.to_s
|
|
195
198
|
end
|
|
196
199
|
|
|
197
200
|
def inspect
|
|
@@ -222,44 +225,59 @@ class SiteInspector
|
|
|
222
225
|
#
|
|
223
226
|
# Returns a complete hash of the domain's information
|
|
224
227
|
def to_h(options = {})
|
|
228
|
+
return {} unless host
|
|
229
|
+
|
|
225
230
|
prefetch
|
|
226
231
|
|
|
227
232
|
hash = {
|
|
228
|
-
host:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
233
|
+
host: canonical_endpoint.host.to_s,
|
|
234
|
+
tld: canonical_endpoint.host.tld,
|
|
235
|
+
sld: canonical_endpoint.host.sld,
|
|
236
|
+
trd: canonical_endpoint.host.trd,
|
|
237
|
+
up: up?,
|
|
238
|
+
responds: responds?,
|
|
239
|
+
www: www?,
|
|
240
|
+
root: root?,
|
|
241
|
+
https: https?,
|
|
242
|
+
enforces_https: enforces_https?,
|
|
243
|
+
downgrades_https: downgrades_https?,
|
|
244
|
+
canonically_www: canonically_www?,
|
|
245
|
+
canonically_https: canonically_https?,
|
|
246
|
+
redirect: redirect?,
|
|
247
|
+
hsts: hsts?,
|
|
248
|
+
hsts_subdomains: hsts_subdomains?,
|
|
241
249
|
hsts_preload_ready: hsts_preload_ready?,
|
|
242
250
|
canonical_endpoint: canonical_endpoint.to_h(options)
|
|
243
251
|
}
|
|
244
252
|
|
|
245
253
|
if options['all']
|
|
246
|
-
hash
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
254
|
+
hash[:endpoints] = {
|
|
255
|
+
https: {
|
|
256
|
+
root: endpoints[0].to_h(options),
|
|
257
|
+
www: endpoints[1].to_h(options)
|
|
258
|
+
},
|
|
259
|
+
http: {
|
|
260
|
+
root: endpoints[2].to_h(options),
|
|
261
|
+
www: endpoints[3].to_h(options)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
256
264
|
end
|
|
257
265
|
|
|
258
266
|
hash
|
|
259
267
|
end
|
|
260
268
|
|
|
261
|
-
def to_json
|
|
269
|
+
def to_json(*_args)
|
|
262
270
|
to_h.to_json
|
|
263
271
|
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
# Rebuild the domain without a leading `www` label
|
|
276
|
+
def strip_www(domain)
|
|
277
|
+
return domain unless domain.trd == 'www' || domain.trd.start_with?('www.')
|
|
278
|
+
|
|
279
|
+
trd = domain.trd.delete_prefix('www').delete_prefix('.')
|
|
280
|
+
PublicSuffix::Domain.new(domain.tld, domain.sld, trd.empty? ? nil : trd)
|
|
281
|
+
end
|
|
264
282
|
end
|
|
265
283
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'json'
|
|
2
4
|
require 'open3'
|
|
3
5
|
|
|
@@ -7,24 +9,28 @@ class SiteInspector
|
|
|
7
9
|
class Pa11yError < RuntimeError; end
|
|
8
10
|
|
|
9
11
|
STANDARDS = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
12
|
+
wcag2a: 'WCAG2A', # Default standard
|
|
13
|
+
wcag2aa: 'WCAG2AA',
|
|
14
|
+
wcag2aaa: 'WCAG2AAA',
|
|
15
|
+
section508: 'Section508'
|
|
16
|
+
}.freeze
|
|
15
17
|
|
|
16
18
|
DEFAULT_LEVEL = :error
|
|
17
19
|
|
|
18
|
-
REQUIRED_PA11Y_VERSION = '~>
|
|
20
|
+
REQUIRED_PA11Y_VERSION = '~> 5.0'
|
|
19
21
|
|
|
20
22
|
class << self
|
|
21
23
|
def pa11y_version
|
|
22
|
-
@pa11y_version ||=
|
|
24
|
+
@pa11y_version ||= begin
|
|
25
|
+
output, status = run_command('--version')
|
|
26
|
+
output.strip if status.exitstatus.zero?
|
|
27
|
+
end
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def pa11y?
|
|
26
31
|
return @pa11y_detected if defined? @pa11y_detected
|
|
27
|
-
|
|
32
|
+
|
|
33
|
+
@pa11y_detected = !!pa11y.detect
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def enabled?
|
|
@@ -33,11 +39,14 @@ class SiteInspector
|
|
|
33
39
|
|
|
34
40
|
def pa11y
|
|
35
41
|
@pa11y ||= begin
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Cliver::Dependency.new('pa11y', REQUIRED_PA11Y_VERSION, path: path)
|
|
42
|
+
path = ['*', './bin', './node_modules/.bin'].join(File::PATH_SEPARATOR)
|
|
43
|
+
Cliver::Dependency.new('pa11y', REQUIRED_PA11Y_VERSION, path:)
|
|
39
44
|
end
|
|
40
45
|
end
|
|
46
|
+
|
|
47
|
+
def run_command(args)
|
|
48
|
+
Open3.capture2e(pa11y.detect, *args)
|
|
49
|
+
end
|
|
41
50
|
end
|
|
42
51
|
|
|
43
52
|
def level
|
|
@@ -45,12 +54,13 @@ class SiteInspector
|
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
def level=(level)
|
|
48
|
-
|
|
57
|
+
raise ArgumentError, "Invalid level '#{level}'" unless %i[error warning notice].include?(level)
|
|
58
|
+
|
|
49
59
|
@level = level
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def standard?(standard)
|
|
53
|
-
STANDARDS.
|
|
63
|
+
STANDARDS.key?(standard)
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
def standard
|
|
@@ -58,7 +68,8 @@ class SiteInspector
|
|
|
58
68
|
end
|
|
59
69
|
|
|
60
70
|
def standard=(standard)
|
|
61
|
-
|
|
71
|
+
raise ArgumentError, "Unknown standard '#{standard}'" unless standard?(standard)
|
|
72
|
+
|
|
62
73
|
@standard = standard
|
|
63
74
|
end
|
|
64
75
|
|
|
@@ -71,13 +82,16 @@ class SiteInspector
|
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
def check
|
|
85
|
+
return {} unless endpoint.up?
|
|
86
|
+
return {} if endpoint.redirect?
|
|
87
|
+
|
|
74
88
|
@check ||= run_pa11y(standard)
|
|
75
89
|
rescue Pa11yError
|
|
76
90
|
nil
|
|
77
91
|
end
|
|
78
|
-
|
|
92
|
+
alias to_h check
|
|
79
93
|
|
|
80
|
-
def method_missing(method_sym, *arguments, &
|
|
94
|
+
def method_missing(method_sym, *arguments, &)
|
|
81
95
|
if standard?(method_sym)
|
|
82
96
|
run_pa11y(method_sym)
|
|
83
97
|
else
|
|
@@ -85,7 +99,7 @@ class SiteInspector
|
|
|
85
99
|
end
|
|
86
100
|
end
|
|
87
101
|
|
|
88
|
-
def
|
|
102
|
+
def respond_to_missing?(method_sym, include_private = false)
|
|
89
103
|
if standard?(method_sym)
|
|
90
104
|
true
|
|
91
105
|
else
|
|
@@ -97,7 +111,7 @@ class SiteInspector
|
|
|
97
111
|
|
|
98
112
|
def run_pa11y(standard)
|
|
99
113
|
self.class.pa11y.detect! unless ENV['SKIP_PA11Y_CHECK']
|
|
100
|
-
|
|
114
|
+
raise ArgumentError, "Unknown standard '#{standard}'" unless standard?(standard)
|
|
101
115
|
|
|
102
116
|
args = [
|
|
103
117
|
'--standard', STANDARDS[standard],
|
|
@@ -105,23 +119,19 @@ class SiteInspector
|
|
|
105
119
|
'--level', level.to_s,
|
|
106
120
|
endpoint.uri.to_s
|
|
107
121
|
]
|
|
108
|
-
output, status = run_command(args)
|
|
122
|
+
output, status = self.class.run_command(args)
|
|
109
123
|
|
|
110
124
|
# Pa11y exit codes: https://github.com/nature/pa11y#exit-codes
|
|
111
125
|
# 0: No errors, 1: Technical error within pa11y, 2: accessibility error (configurable via --level)
|
|
112
|
-
|
|
126
|
+
raise Pa11yError if status.exitstatus == 1
|
|
113
127
|
|
|
114
128
|
{
|
|
115
|
-
valid:
|
|
129
|
+
valid: status.exitstatus.zero?,
|
|
116
130
|
results: JSON.parse(output)
|
|
117
131
|
}
|
|
118
132
|
rescue Pa11yError, JSON::ParserError
|
|
119
133
|
raise Pa11yError, "Command `pa11y #{args.join(' ')}` failed: #{output}"
|
|
120
134
|
end
|
|
121
|
-
|
|
122
|
-
def run_command(args)
|
|
123
|
-
Open3.capture2e(self.class.pa11y.path, *args)
|
|
124
|
-
end
|
|
125
135
|
end
|
|
126
136
|
end
|
|
127
137
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class SiteInspector
|
|
2
4
|
class Endpoint
|
|
3
5
|
class Check
|
|
@@ -21,7 +23,7 @@ class SiteInspector
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def host
|
|
24
|
-
|
|
26
|
+
endpoint.host.to_s
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def inspect
|
|
@@ -32,6 +34,10 @@ class SiteInspector
|
|
|
32
34
|
self.class.name
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
def uri_for(_key)
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
35
41
|
class << self
|
|
36
42
|
@@enabled = true
|
|
37
43
|
|
|
@@ -40,11 +46,11 @@ class SiteInspector
|
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def enabled?
|
|
43
|
-
|
|
49
|
+
!!@@enabled
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def enabled=(value)
|
|
47
|
-
@@enabled = !!
|
|
53
|
+
@@enabled = !!value
|
|
48
54
|
end
|
|
49
55
|
end
|
|
50
56
|
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SiteInspector
|
|
4
|
+
class Endpoint
|
|
5
|
+
class Content < Check
|
|
6
|
+
PATHS = [
|
|
7
|
+
'robots.txt', 'sitemap.xml', 'humans.txt', 'vulnerability-disclosure-policy', 'security.txt', '.well-known/security.txt', 'data.json'
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def paths
|
|
12
|
+
@paths ||= PATHS.to_h { |p| [key_for(p), p] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def path_for(key)
|
|
16
|
+
paths[key]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def key_for(path)
|
|
20
|
+
path.gsub(/(\.|-)/, '_').to_sym
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Given a path (e.g, "/data"), check if the given path exists on the canonical endpoint
|
|
25
|
+
def path_exists?(path)
|
|
26
|
+
return unless proper_404s?
|
|
27
|
+
|
|
28
|
+
@exists ||= {}
|
|
29
|
+
@exists[path] ||= endpoint.up? && endpoint.request(path:, followlocation: true).success?
|
|
30
|
+
rescue URI::InvalidURIError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The default Check#response method is from a HEAD request
|
|
35
|
+
# The content check has a special response which includes the body from a GET request
|
|
36
|
+
def response
|
|
37
|
+
@response ||= endpoint.request(method: :get, followlocation: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def document
|
|
41
|
+
require 'nokogiri'
|
|
42
|
+
@doc ||= Nokogiri::HTML response.body if response
|
|
43
|
+
end
|
|
44
|
+
alias doc document
|
|
45
|
+
|
|
46
|
+
def body
|
|
47
|
+
@body ||= document.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, replace: '')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def method_missing(method_sym, *arguments, &)
|
|
51
|
+
key = method_sym.to_s.gsub(/\?$/, '').to_sym
|
|
52
|
+
if respond_to_missing?(key)
|
|
53
|
+
path = self.class.paths[key]
|
|
54
|
+
path_exists?(path)
|
|
55
|
+
else
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def respond_to_missing?(method_sym, include_private = false)
|
|
61
|
+
if self.class.paths.key?(method_sym)
|
|
62
|
+
true
|
|
63
|
+
else
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def security_txt?
|
|
69
|
+
@security_txt ||= if proper_404s?
|
|
70
|
+
path_exists?('security.txt') || path_exists?('.well-known/security.txt')
|
|
71
|
+
else
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def uri_for(key)
|
|
77
|
+
return nil unless self.class.paths.key?(key)
|
|
78
|
+
|
|
79
|
+
endpoint.join(self.class.paths[key]) if proper_404s?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def doctype
|
|
83
|
+
document.internal_subset.external_id
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def generator
|
|
87
|
+
@generator ||= begin
|
|
88
|
+
tag = document.at('meta[name="generator"]')
|
|
89
|
+
tag['content'] if tag
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prefetch
|
|
94
|
+
return unless endpoint.up?
|
|
95
|
+
|
|
96
|
+
paths = self.class.paths.values.concat(random_paths)
|
|
97
|
+
paths.each do |path|
|
|
98
|
+
request = endpoint.build_request(path:, followlocation: true)
|
|
99
|
+
SiteInspector.hydra.queue(request)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Request for content is a GET, not a HEAD request
|
|
103
|
+
request = endpoint.build_request(method: :get, followlocation: true)
|
|
104
|
+
SiteInspector.hydra.queue(request)
|
|
105
|
+
|
|
106
|
+
SiteInspector.hydra.run
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def proper_404s?
|
|
110
|
+
@proper_404s ||= begin
|
|
111
|
+
return unless endpoint.up?
|
|
112
|
+
|
|
113
|
+
random_paths.all? do |random_path|
|
|
114
|
+
endpoint.request(path: random_path, followlocation: true).code == 404
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_h
|
|
120
|
+
return {} unless endpoint.up?
|
|
121
|
+
return {} if endpoint.redirect?
|
|
122
|
+
return @hash if defined?(@hash)
|
|
123
|
+
|
|
124
|
+
prefetch
|
|
125
|
+
@hash = {
|
|
126
|
+
doctype:,
|
|
127
|
+
generator:,
|
|
128
|
+
proper_404s: proper_404s?
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
self.class.paths.each do |key, _path|
|
|
132
|
+
@hash[key] = send(key) unless key.to_s.start_with?('_')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@hash[:security_txt] = security_txt?
|
|
136
|
+
@hash
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def random_paths
|
|
142
|
+
require 'securerandom'
|
|
143
|
+
@random_paths ||= [
|
|
144
|
+
SecureRandom.hex,
|
|
145
|
+
"#{SecureRandom.hex}.html",
|
|
146
|
+
"#{SecureRandom.hex}.json"
|
|
147
|
+
]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'http-cookie'
|
|
4
|
+
|
|
5
|
+
class SiteInspector
|
|
6
|
+
class Endpoint
|
|
7
|
+
class Cookies < Check
|
|
8
|
+
def any?(&block)
|
|
9
|
+
if cookie_header.nil? || cookie_header.empty?
|
|
10
|
+
false
|
|
11
|
+
elsif block
|
|
12
|
+
all.any?(&block)
|
|
13
|
+
else
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
alias cookies? any?
|
|
18
|
+
|
|
19
|
+
# Returns an array of HTTP::Cookie objects parsed from the Set-Cookie headers
|
|
20
|
+
def all
|
|
21
|
+
@cookies ||= cookie_header.flat_map { |c| HTTP::Cookie.parse(c, endpoint.uri.to_s) } if cookies?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](key)
|
|
25
|
+
all.find { |cookie| cookie.name == key } if cookies?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Does at least one cookie set both the Secure and HttpOnly flags?
|
|
29
|
+
def secure?
|
|
30
|
+
return false unless cookies?
|
|
31
|
+
|
|
32
|
+
all.any? { |cookie| cookie.secure? && cookie.httponly? }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
return {} unless endpoint.up?
|
|
37
|
+
return {} if endpoint.redirect?
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
cookie?: any?,
|
|
41
|
+
secure?: secure?
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def cookie_header
|
|
48
|
+
# Cookie header may be an array or string, always return an array
|
|
49
|
+
[endpoint.headers.all['set-cookie']].flatten.compact
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|