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,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.downcase
7
- host = host.sub(/^https?\:/, '')
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.new("https://#{host}", domain: self),
17
- Endpoint.new("https://www.#{host}", domain: self),
18
- Endpoint.new("http://#{host}", domain: self),
19
- Endpoint.new("http://www.#{host}", domain: self)
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
- endpoints.select(&:http?).all? { |e| !e.up? || (e.redirect && e.redirect.https?) }
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
- fail 'Not implemented. Halp?'
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
- # * The 'canonical' endpoint gets an immediate internal redirect to HTTP.
99
+ # * Any HTTPS endpoint redirects to HTTP.
101
100
  #
102
- # TODO: the redirect must be internal.
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
- canonical_endpoint.redirect? && canonical_endpoint.redirect.http?
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 && e.redirect.www? }
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 && e.redirect.https? }
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 && canonical_endpoint.hsts.enabled?
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: host,
229
- up: up?,
230
- responds: responds?,
231
- www: www?,
232
- root: root?,
233
- https: https?,
234
- enforces_https: enforces_https?,
235
- downgrades_https: downgrades_https?,
236
- canonically_www: canonically_www?,
237
- canonically_https: canonically_https?,
238
- redirect: redirect?,
239
- hsts: hsts?,
240
- hsts_subdomains: hsts_subdomains?,
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.merge!(endpoints: {
247
- https: {
248
- root: endpoints[0].to_h(options),
249
- www: endpoints[1].to_h(options)
250
- },
251
- http: {
252
- root: endpoints[2].to_h(options),
253
- www: endpoints[3].to_h(options)
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SiteInspector
4
+ class DomainParser
5
+ include NaughtyOrNice
6
+
7
+ def self.parse(domain)
8
+ new(domain).domain
9
+ end
10
+ end
11
+ 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
- section508: 'Section508', # Default standard
11
- wcag2a: 'WCAG2A',
12
- wcag2aa: 'WCAG2AA',
13
- wcag2aaa: 'WCAG2AAA'
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 = '~> 2.1'
20
+ REQUIRED_PA11Y_VERSION = '~> 5.0'
19
21
 
20
22
  class << self
21
23
  def pa11y_version
22
- @pa11y_version ||= 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
- @pa11y_detected = !!(pa11y.detect)
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
- node_bin = File.expand_path('../../../node_modules/pa11y/bin', File.dirname(__FILE__))
37
- path = ['*', node_bin].join(File::PATH_SEPARATOR)
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
- fail ArgumentError, "Invalid level '#{level}'" unless [:error, :warning, :notice].include?(level)
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.keys.include?(standard)
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
- fail ArgumentError, "Unknown standard '#{standard}'" unless standard?(standard)
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
- alias_method :to_h, :check
92
+ alias to_h check
79
93
 
80
- def method_missing(method_sym, *arguments, &block)
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 respond_to?(method_sym, include_private = false)
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
- fail ArgumentError, "Unknown standard '#{standard}'" unless standard?(standard)
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
- fail Pa11yError if status == 1
126
+ raise Pa11yError if status.exitstatus == 1
113
127
 
114
128
  {
115
- valid: status == 0,
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
- request.base_url.host
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
- !!(@@enabled)
49
+ !!@@enabled
44
50
  end
45
51
 
46
52
  def enabled=(value)
47
- @@enabled = !!(value)
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