hedra 2.0.3 → 2.1.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.
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'socket'
5
+ require 'uri'
6
+
7
+ module Hedra
8
+ # Check HTTP protocol version and TLS version
9
+ class ProtocolChecker
10
+ MINIMUM_TLS_VERSION = 'TLSv1.2'
11
+ RECOMMENDED_TLS_VERSION = 'TLSv1.3'
12
+
13
+ TLS_VERSION_MAP = {
14
+ 0x0300 => 'SSLv3',
15
+ 0x0301 => 'TLSv1.0',
16
+ 0x0302 => 'TLSv1.1',
17
+ 0x0303 => 'TLSv1.2',
18
+ 0x0304 => 'TLSv1.3'
19
+ }.freeze
20
+
21
+ def check(url, response = nil)
22
+ findings = []
23
+ uri = URI.parse(url)
24
+
25
+ # Check HTTP protocol version
26
+ if response
27
+ findings.concat(check_http_version(response))
28
+ end
29
+
30
+ # Check TLS version for HTTPS
31
+ if uri.scheme == 'https'
32
+ findings.concat(check_tls_version(uri.host, uri.port || 443))
33
+ elsif uri.scheme == 'http'
34
+ findings << {
35
+ header: 'protocol',
36
+ issue: 'Using insecure HTTP protocol instead of HTTPS',
37
+ severity: :critical,
38
+ recommended_fix: 'Migrate to HTTPS with valid SSL/TLS certificate'
39
+ }
40
+ end
41
+
42
+ findings
43
+ rescue StandardError => e
44
+ warn "Protocol check failed: #{e.message}" if ENV['DEBUG']
45
+ []
46
+ end
47
+
48
+ private
49
+
50
+ def check_http_version(response)
51
+ findings = []
52
+
53
+ # Try to detect HTTP version from response
54
+ http_version = detect_http_version(response)
55
+
56
+ case http_version
57
+ when '1.0'
58
+ findings << {
59
+ header: 'protocol',
60
+ issue: 'Using outdated HTTP/1.0 protocol',
61
+ severity: :warning,
62
+ recommended_fix: 'Upgrade to HTTP/2 or HTTP/3 for better performance and security'
63
+ }
64
+ when '1.1'
65
+ findings << {
66
+ header: 'protocol',
67
+ issue: 'Using HTTP/1.1 - consider upgrading to HTTP/2 or HTTP/3',
68
+ severity: :info,
69
+ recommended_fix: 'Upgrade to HTTP/2 or HTTP/3 for multiplexing and improved security'
70
+ }
71
+ when '2', '2.0'
72
+ # HTTP/2 is good, but HTTP/3 is better
73
+ findings << {
74
+ header: 'protocol',
75
+ issue: 'Using HTTP/2 - HTTP/3 available for better performance',
76
+ severity: :info,
77
+ recommended_fix: 'Consider upgrading to HTTP/3 (QUIC) for improved performance'
78
+ }
79
+ when '3', '3.0'
80
+ # HTTP/3 is optimal - no finding
81
+ when nil
82
+ # Unable to detect - skip
83
+ else
84
+ findings << {
85
+ header: 'protocol',
86
+ issue: "Unknown HTTP version: #{http_version}",
87
+ severity: :info,
88
+ recommended_fix: 'Verify HTTP protocol version'
89
+ }
90
+ end
91
+
92
+ findings
93
+ end
94
+
95
+ def detect_http_version(response)
96
+ # Try to get version from response object
97
+ if response.respond_to?(:version)
98
+ return response.version.to_s
99
+ end
100
+
101
+ # Try to detect from headers
102
+ # HTTP/2 typically has lowercase headers
103
+ if response.headers.keys.all? { |k| k == k.downcase }
104
+ return '2'
105
+ end
106
+
107
+ # Check for HTTP/2 specific pseudo-headers
108
+ if response.headers.keys.any? { |k| k.start_with?(':') }
109
+ return '2'
110
+ end
111
+
112
+ # Check alt-svc header for HTTP/3
113
+ if response.headers['alt-svc']&.include?('h3')
114
+ return '3'
115
+ end
116
+
117
+ # Default assumption for HTTPS
118
+ '1.1'
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ def check_tls_version(host, port)
124
+ findings = []
125
+
126
+ tls_info = probe_tls_versions(host, port)
127
+ return findings if tls_info.empty?
128
+
129
+ # Check if weak protocols are supported
130
+ weak_protocols = tls_info.select { |v| weak_tls_version?(v) }
131
+ if weak_protocols.any?
132
+ findings << {
133
+ header: 'tls-version',
134
+ issue: "Weak TLS versions supported: #{weak_protocols.join(', ')}",
135
+ severity: :critical,
136
+ recommended_fix: "Disable #{weak_protocols.join(', ')} and use only TLS 1.2 or higher"
137
+ }
138
+ end
139
+
140
+ # Check if TLS 1.2 is the minimum
141
+ if tls_info.include?('TLSv1.1') || tls_info.include?('TLSv1.0')
142
+ findings << {
143
+ header: 'tls-version',
144
+ issue: 'TLS 1.0/1.1 supported - deprecated and insecure',
145
+ severity: :critical,
146
+ recommended_fix: 'Disable TLS 1.0 and 1.1, use TLS 1.2+ only'
147
+ }
148
+ end
149
+
150
+ # Recommend TLS 1.3 if not supported
151
+ unless tls_info.include?('TLSv1.3')
152
+ findings << {
153
+ header: 'tls-version',
154
+ issue: 'TLS 1.3 not supported',
155
+ severity: :info,
156
+ recommended_fix: 'Enable TLS 1.3 for improved security and performance'
157
+ }
158
+ end
159
+
160
+ # Check negotiated version
161
+ negotiated = tls_info.last
162
+ if negotiated && negotiated < MINIMUM_TLS_VERSION
163
+ findings << {
164
+ header: 'tls-version',
165
+ issue: "Negotiated TLS version #{negotiated} is below minimum (#{MINIMUM_TLS_VERSION})",
166
+ severity: :critical,
167
+ recommended_fix: "Enforce minimum TLS version #{MINIMUM_TLS_VERSION}"
168
+ }
169
+ end
170
+
171
+ findings
172
+ rescue StandardError => e
173
+ warn "TLS version check failed: #{e.message}" if ENV['DEBUG']
174
+ []
175
+ end
176
+
177
+ def probe_tls_versions(host, port)
178
+ supported_versions = []
179
+ timeout = 5
180
+
181
+ # Try to connect with different TLS versions
182
+ [
183
+ OpenSSL::SSL::TLS1_3_VERSION,
184
+ OpenSSL::SSL::TLS1_2_VERSION,
185
+ OpenSSL::SSL::TLS1_1_VERSION,
186
+ OpenSSL::SSL::TLS1_VERSION
187
+ ].each do |version|
188
+ begin
189
+ tcp_socket = Socket.tcp(host, port, connect_timeout: timeout)
190
+ ssl_context = OpenSSL::SSL::SSLContext.new
191
+
192
+ # Set specific TLS version
193
+ ssl_context.min_version = version
194
+ ssl_context.max_version = version
195
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
196
+
197
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
198
+ ssl_socket.sync_close = true
199
+ ssl_socket.connect
200
+
201
+ # Get actual version
202
+ actual_version = ssl_socket.ssl_version
203
+ supported_versions << actual_version unless supported_versions.include?(actual_version)
204
+
205
+ ssl_socket.close
206
+ rescue OpenSSL::SSL::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError
207
+ # Version not supported or connection failed
208
+ next
209
+ rescue StandardError => e
210
+ warn "TLS probe error for #{version}: #{e.message}" if ENV['DEBUG']
211
+ next
212
+ ensure
213
+ tcp_socket&.close rescue nil
214
+ end
215
+ end
216
+
217
+ supported_versions.uniq.sort
218
+ rescue StandardError => e
219
+ warn "TLS version probe failed: #{e.message}" if ENV['DEBUG']
220
+ []
221
+ end
222
+
223
+ def weak_tls_version?(version)
224
+ weak_versions = ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.0', 'TLSv1.1']
225
+ weak_versions.include?(version)
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'digest'
5
+
6
+ module Hedra
7
+ # Check for Subresource Integrity (SRI) on external resources
8
+ class SriChecker
9
+ EXTERNAL_RESOURCE_PATTERN = /<(script|link)\s+[^>]*>/i.freeze
10
+ SRC_PATTERN = /(?:src|href)=["']([^"']+)["']/i.freeze
11
+ INTEGRITY_PATTERN = /integrity=["']([^"']+)["']/i.freeze
12
+ CROSSORIGIN_PATTERN = /crossorigin(?:=["']([^"']+)["'])?/i.freeze
13
+
14
+ def initialize(http_client: nil)
15
+ @http_client = http_client
16
+ end
17
+
18
+ def check(url, html_content = nil)
19
+ findings = []
20
+
21
+ # Fetch HTML if not provided
22
+ html_content ||= fetch_html(url)
23
+ return findings unless html_content
24
+
25
+ base_uri = URI.parse(url)
26
+ external_resources = extract_external_resources(html_content, base_uri)
27
+
28
+ return findings if external_resources.empty?
29
+
30
+ external_resources.each do |resource|
31
+ next if resource[:has_integrity]
32
+
33
+ findings << {
34
+ header: 'subresource-integrity',
35
+ issue: "External #{resource[:type]} missing SRI: #{truncate_url(resource[:url])}",
36
+ severity: :warning,
37
+ recommended_fix: "Add integrity attribute with hash: integrity=\"sha384-...\" crossorigin=\"anonymous\""
38
+ }
39
+ end
40
+
41
+ # Check for crossorigin without integrity
42
+ external_resources.each do |resource|
43
+ if resource[:has_integrity] && !resource[:has_crossorigin]
44
+ findings << {
45
+ header: 'subresource-integrity',
46
+ issue: "SRI without crossorigin attribute: #{truncate_url(resource[:url])}",
47
+ severity: :info,
48
+ recommended_fix: 'Add crossorigin="anonymous" when using integrity attribute'
49
+ }
50
+ end
51
+ end
52
+
53
+ findings
54
+ rescue StandardError => e
55
+ warn "SRI check failed: #{e.message}" if ENV['DEBUG']
56
+ []
57
+ end
58
+
59
+ private
60
+
61
+ def fetch_html(url)
62
+ return nil unless @http_client
63
+
64
+ response = @http_client.get(url)
65
+ content_type = response.headers['Content-Type'].to_s
66
+
67
+ # Only process HTML content
68
+ return nil unless content_type.include?('text/html')
69
+
70
+ response.body.to_s
71
+ rescue StandardError => e
72
+ warn "Failed to fetch HTML for SRI check: #{e.message}" if ENV['DEBUG']
73
+ nil
74
+ end
75
+
76
+ def extract_external_resources(html, base_uri)
77
+ resources = []
78
+
79
+ html.scan(EXTERNAL_RESOURCE_PATTERN) do |match|
80
+ tag = match[0]
81
+ full_tag = ::Regexp.last_match(0)
82
+
83
+ # Extract src/href
84
+ src_match = full_tag.match(SRC_PATTERN)
85
+ next unless src_match
86
+
87
+ resource_url = src_match[1]
88
+ next if resource_url.nil? || resource_url.empty?
89
+
90
+ # Skip data URIs, inline scripts, and relative URLs to same origin
91
+ next if resource_url.start_with?('data:', 'blob:', '#', 'javascript:')
92
+
93
+ # Resolve relative URLs
94
+ absolute_url = resolve_url(resource_url, base_uri)
95
+ next unless absolute_url
96
+
97
+ # Check if external
98
+ resource_uri = URI.parse(absolute_url)
99
+ next if same_origin?(base_uri, resource_uri)
100
+
101
+ # Check for integrity and crossorigin attributes
102
+ has_integrity = full_tag.match?(INTEGRITY_PATTERN)
103
+ has_crossorigin = full_tag.match?(CROSSORIGIN_PATTERN)
104
+
105
+ resources << {
106
+ type: tag.downcase,
107
+ url: absolute_url,
108
+ has_integrity: has_integrity,
109
+ has_crossorigin: has_crossorigin
110
+ }
111
+ rescue URI::InvalidURIError
112
+ # Skip invalid URIs
113
+ next
114
+ end
115
+
116
+ resources.uniq { |r| r[:url] }
117
+ end
118
+
119
+ def resolve_url(url, base_uri)
120
+ return url if url.match?(%r{^https?://})
121
+
122
+ if url.start_with?('//')
123
+ "#{base_uri.scheme}:#{url}"
124
+ elsif url.start_with?('/')
125
+ "#{base_uri.scheme}://#{base_uri.host}#{base_uri.port && ![80, 443].include?(base_uri.port) ? ":#{base_uri.port}" : ''}#{url}"
126
+ else
127
+ # Relative URL
128
+ base_path = base_uri.path.split('/')[0..-2].join('/')
129
+ "#{base_uri.scheme}://#{base_uri.host}#{base_uri.port && ![80, 443].include?(base_uri.port) ? ":#{base_uri.port}" : ''}#{base_path}/#{url}"
130
+ end
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ def same_origin?(uri1, uri2)
136
+ uri1.scheme == uri2.scheme &&
137
+ uri1.host == uri2.host &&
138
+ (uri1.port || default_port(uri1.scheme)) == (uri2.port || default_port(uri2.scheme))
139
+ end
140
+
141
+ def default_port(scheme)
142
+ scheme == 'https' ? 443 : 80
143
+ end
144
+
145
+ def truncate_url(url, max_length = 60)
146
+ return url if url.length <= max_length
147
+
148
+ "#{url[0...max_length]}..."
149
+ end
150
+ end
151
+ end
data/lib/hedra/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hedra
4
- VERSION = '2.0.3'
4
+ VERSION = '2.1.0'
5
5
  end
data/lib/hedra.rb CHANGED
@@ -22,5 +22,10 @@ require_relative 'hedra/http_client'
22
22
  require_relative 'hedra/plugin_manager'
23
23
  require_relative 'hedra/exporter'
24
24
  require_relative 'hedra/html_reporter'
25
+ require_relative 'hedra/sri_checker'
26
+ require_relative 'hedra/cors_checker'
27
+ require_relative 'hedra/protocol_checker'
28
+ require_relative 'hedra/ct_checker'
29
+ require_relative 'hedra/dns_checker'
25
30
  require_relative 'hedra/analyzer'
26
31
  require_relative 'hedra/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hedra
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.3
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - bl4ckstack
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.8'
82
+ - !ruby/object:Gem::Dependency
83
+ name: resolv
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 0.2.0
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 0.2.0
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: thor
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -130,14 +144,19 @@ files:
130
144
  - lib/hedra/circuit_breaker.rb
131
145
  - lib/hedra/cli.rb
132
146
  - lib/hedra/config.rb
147
+ - lib/hedra/cors_checker.rb
148
+ - lib/hedra/ct_checker.rb
149
+ - lib/hedra/dns_checker.rb
133
150
  - lib/hedra/exporter.rb
134
151
  - lib/hedra/html_reporter.rb
135
152
  - lib/hedra/http_client.rb
136
153
  - lib/hedra/plugin_manager.rb
137
154
  - lib/hedra/progress_tracker.rb
155
+ - lib/hedra/protocol_checker.rb
138
156
  - lib/hedra/rate_limiter.rb
139
157
  - lib/hedra/scorer.rb
140
158
  - lib/hedra/security_txt_checker.rb
159
+ - lib/hedra/sri_checker.rb
141
160
  - lib/hedra/url_validator.rb
142
161
  - lib/hedra/version.rb
143
162
  homepage: https://github.com/bl4ckstack/hedra