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.
- checksums.yaml +4 -4
- data/README.md +115 -2
- data/lib/hedra/analyzer.rb +25 -2
- data/lib/hedra/cli.rb +53 -9
- data/lib/hedra/cors_checker.rb +251 -0
- data/lib/hedra/ct_checker.rb +286 -0
- data/lib/hedra/dns_checker.rb +274 -0
- data/lib/hedra/protocol_checker.rb +228 -0
- data/lib/hedra/sri_checker.rb +151 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +5 -0
- metadata +20 -1
|
@@ -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
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
|
|
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
|