hedra 2.0.2 → 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 +54 -15
- data/lib/hedra/banner.rb +55 -0
- data/lib/hedra/cache.rb +58 -22
- data/lib/hedra/circuit_breaker.rb +24 -15
- data/lib/hedra/cli.rb +67 -13
- data/lib/hedra/config.rb +11 -2
- 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/plugin_manager.rb +23 -1
- data/lib/hedra/progress_tracker.rb +6 -1
- data/lib/hedra/protocol_checker.rb +228 -0
- data/lib/hedra/rate_limiter.rb +6 -0
- data/lib/hedra/scorer.rb +24 -2
- data/lib/hedra/security_txt_checker.rb +25 -2
- data/lib/hedra/sri_checker.rb +151 -0
- data/lib/hedra/url_validator.rb +94 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +7 -0
- metadata +22 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
# Check DNS security features (DNSSEC, CAA records)
|
|
8
|
+
class DnsChecker
|
|
9
|
+
CAA_TAG_ISSUE = 'issue'
|
|
10
|
+
CAA_TAG_ISSUEWILD = 'issuewild'
|
|
11
|
+
CAA_TAG_IODEF = 'iodef'
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@resolver = Resolv::DNS.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check(url)
|
|
18
|
+
findings = []
|
|
19
|
+
uri = URI.parse(url)
|
|
20
|
+
domain = uri.host
|
|
21
|
+
|
|
22
|
+
return findings unless domain
|
|
23
|
+
|
|
24
|
+
# Check DNSSEC
|
|
25
|
+
findings.concat(check_dnssec(domain))
|
|
26
|
+
|
|
27
|
+
# Check CAA records
|
|
28
|
+
findings.concat(check_caa_records(domain))
|
|
29
|
+
|
|
30
|
+
findings
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
warn "DNS check failed: #{e.message}" if ENV['DEBUG']
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def check_dnssec(domain)
|
|
39
|
+
findings = []
|
|
40
|
+
|
|
41
|
+
dnssec_enabled = dnssec_enabled?(domain)
|
|
42
|
+
|
|
43
|
+
unless dnssec_enabled
|
|
44
|
+
findings << {
|
|
45
|
+
header: 'dnssec',
|
|
46
|
+
issue: 'DNSSEC not enabled for domain',
|
|
47
|
+
severity: :warning,
|
|
48
|
+
recommended_fix: 'Enable DNSSEC to prevent DNS spoofing and cache poisoning attacks'
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
findings
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
warn "DNSSEC check failed: #{e.message}" if ENV['DEBUG']
|
|
55
|
+
[{
|
|
56
|
+
header: 'dnssec',
|
|
57
|
+
issue: 'Unable to verify DNSSEC status',
|
|
58
|
+
severity: :info,
|
|
59
|
+
recommended_fix: 'Manually verify DNSSEC configuration'
|
|
60
|
+
}]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_caa_records(domain)
|
|
64
|
+
findings = []
|
|
65
|
+
|
|
66
|
+
caa_records = fetch_caa_records(domain)
|
|
67
|
+
|
|
68
|
+
if caa_records.empty?
|
|
69
|
+
findings << {
|
|
70
|
+
header: 'caa-records',
|
|
71
|
+
issue: 'No CAA records found - any CA can issue certificates',
|
|
72
|
+
severity: :warning,
|
|
73
|
+
recommended_fix: 'Add CAA records to restrict which CAs can issue certificates for your domain'
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
# Validate CAA records
|
|
77
|
+
findings.concat(validate_caa_records(caa_records, domain))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
findings
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
warn "CAA check failed: #{e.message}" if ENV['DEBUG']
|
|
83
|
+
[{
|
|
84
|
+
header: 'caa-records',
|
|
85
|
+
issue: 'Unable to query CAA records',
|
|
86
|
+
severity: :info,
|
|
87
|
+
recommended_fix: 'Manually verify CAA record configuration'
|
|
88
|
+
}]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dnssec_enabled?(domain)
|
|
92
|
+
# Check for DNSSEC by querying DNSKEY records
|
|
93
|
+
# This is a simplified check - full validation would require signature verification
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
# Try to get DNSKEY records
|
|
97
|
+
dnskey_records = query_dns(domain, Resolv::DNS::Resource::IN::ANY)
|
|
98
|
+
|
|
99
|
+
# Look for RRSIG or DNSKEY records
|
|
100
|
+
has_dnssec = dnskey_records.any? do |record|
|
|
101
|
+
record.is_a?(String) && (record.include?('RRSIG') || record.include?('DNSKEY'))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return true if has_dnssec
|
|
105
|
+
|
|
106
|
+
# Alternative: check if resolver supports DNSSEC validation
|
|
107
|
+
# by looking for AD (Authenticated Data) flag
|
|
108
|
+
# This requires a DNSSEC-validating resolver
|
|
109
|
+
|
|
110
|
+
# For now, we'll do a basic check by trying to resolve with DO flag
|
|
111
|
+
check_dnssec_with_dig(domain)
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
warn "DNSSEC detection error: #{e.message}" if ENV['DEBUG']
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_dnssec_with_dig(domain)
|
|
119
|
+
# Use system dig command if available for more accurate DNSSEC check
|
|
120
|
+
return false unless command_available?('dig')
|
|
121
|
+
|
|
122
|
+
output = `dig +dnssec #{domain} 2>&1`
|
|
123
|
+
|
|
124
|
+
# Check for RRSIG in response
|
|
125
|
+
output.include?('RRSIG') && !output.include?('SERVFAIL')
|
|
126
|
+
rescue StandardError
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fetch_caa_records(domain)
|
|
131
|
+
caa_records = []
|
|
132
|
+
|
|
133
|
+
# Try to fetch CAA records using Resolv
|
|
134
|
+
# Note: Ruby's Resolv doesn't have built-in CAA support
|
|
135
|
+
# We'll try using system tools as fallback
|
|
136
|
+
|
|
137
|
+
caa_records = fetch_caa_with_dig(domain) if command_available?('dig')
|
|
138
|
+
|
|
139
|
+
# If dig not available, try host command
|
|
140
|
+
caa_records = fetch_caa_with_host(domain) if caa_records.empty? && command_available?('host')
|
|
141
|
+
|
|
142
|
+
caa_records
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
warn "CAA fetch error: #{e.message}" if ENV['DEBUG']
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch_caa_with_dig(domain)
|
|
149
|
+
output = `dig +short CAA #{domain} 2>&1`
|
|
150
|
+
return [] if output.nil? || output.empty? || output.include?('SERVFAIL')
|
|
151
|
+
|
|
152
|
+
parse_caa_from_dig(output)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fetch_caa_with_host(domain)
|
|
158
|
+
output = `host -t CAA #{domain} 2>&1`
|
|
159
|
+
return [] if output.nil? || output.empty? || output.include?('has no CAA')
|
|
160
|
+
|
|
161
|
+
parse_caa_from_host(output)
|
|
162
|
+
rescue StandardError
|
|
163
|
+
[]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_caa_from_dig(output)
|
|
167
|
+
records = []
|
|
168
|
+
|
|
169
|
+
output.each_line do |line|
|
|
170
|
+
line = line.strip
|
|
171
|
+
next if line.empty?
|
|
172
|
+
|
|
173
|
+
# CAA format: flags tag "value"
|
|
174
|
+
# Example: 0 issue "letsencrypt.org"
|
|
175
|
+
if line.match(/(\d+)\s+(\w+)\s+"([^"]+)"/)
|
|
176
|
+
flags = ::Regexp.last_match(1).to_i
|
|
177
|
+
tag = ::Regexp.last_match(2)
|
|
178
|
+
value = ::Regexp.last_match(3)
|
|
179
|
+
|
|
180
|
+
records << {
|
|
181
|
+
flags: flags,
|
|
182
|
+
tag: tag,
|
|
183
|
+
value: value
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
records
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse_caa_from_host(output)
|
|
192
|
+
records = []
|
|
193
|
+
|
|
194
|
+
output.each_line do |line|
|
|
195
|
+
# Example: example.com has CAA record 0 issue "letsencrypt.org"
|
|
196
|
+
if line.match(/has CAA record (\d+)\s+(\w+)\s+"([^"]+)"/)
|
|
197
|
+
flags = ::Regexp.last_match(1).to_i
|
|
198
|
+
tag = ::Regexp.last_match(2)
|
|
199
|
+
value = ::Regexp.last_match(3)
|
|
200
|
+
|
|
201
|
+
records << {
|
|
202
|
+
flags: flags,
|
|
203
|
+
tag: tag,
|
|
204
|
+
value: value
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
records
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_caa_records(caa_records, domain)
|
|
213
|
+
findings = []
|
|
214
|
+
|
|
215
|
+
# Check for issue tag
|
|
216
|
+
issue_records = caa_records.select { |r| r[:tag] == CAA_TAG_ISSUE }
|
|
217
|
+
|
|
218
|
+
if issue_records.empty?
|
|
219
|
+
findings << {
|
|
220
|
+
header: 'caa-records',
|
|
221
|
+
issue: 'No CAA "issue" tag found - consider adding to restrict certificate issuance',
|
|
222
|
+
severity: :info,
|
|
223
|
+
recommended_fix: 'Add CAA record with issue tag: example.com. CAA 0 issue "ca.example.com"'
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Check for wildcard protection
|
|
228
|
+
issuewild_records = caa_records.select { |r| r[:tag] == CAA_TAG_ISSUEWILD }
|
|
229
|
+
|
|
230
|
+
if issuewild_records.empty? && issue_records.any?
|
|
231
|
+
findings << {
|
|
232
|
+
header: 'caa-records',
|
|
233
|
+
issue: 'No CAA "issuewild" tag - wildcard certificates not explicitly controlled',
|
|
234
|
+
severity: :info,
|
|
235
|
+
recommended_fix: 'Add CAA issuewild record to control wildcard certificate issuance'
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Check for incident reporting
|
|
240
|
+
iodef_records = caa_records.select { |r| r[:tag] == CAA_TAG_IODEF }
|
|
241
|
+
|
|
242
|
+
if iodef_records.empty?
|
|
243
|
+
findings << {
|
|
244
|
+
header: 'caa-records',
|
|
245
|
+
issue: 'No CAA "iodef" tag for incident reporting',
|
|
246
|
+
severity: :info,
|
|
247
|
+
recommended_fix: 'Add CAA iodef record for certificate issuance violation notifications'
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Check for overly permissive CAA
|
|
252
|
+
if issue_records.any? { |r| r[:value] == ';' || r[:value].empty? }
|
|
253
|
+
findings << {
|
|
254
|
+
header: 'caa-records',
|
|
255
|
+
issue: 'CAA record allows all CAs (empty value)',
|
|
256
|
+
severity: :warning,
|
|
257
|
+
recommended_fix: 'Specify explicit CA domains in CAA records'
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
findings
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def query_dns(domain, type)
|
|
265
|
+
@resolver.getresources(domain, type)
|
|
266
|
+
rescue StandardError
|
|
267
|
+
[]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def command_available?(command)
|
|
271
|
+
system("which #{command} > /dev/null 2>&1")
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/hedra/plugin_manager.rb
CHANGED
|
@@ -16,9 +16,17 @@ module Hedra
|
|
|
16
16
|
|
|
17
17
|
def install(path)
|
|
18
18
|
raise Error, "Plugin file not found: #{path}" unless File.exist?(path)
|
|
19
|
+
raise Error, "Not a Ruby file: #{path}" unless path.end_with?('.rb')
|
|
20
|
+
|
|
21
|
+
# Validate plugin syntax before installing
|
|
22
|
+
validate_plugin_syntax(path)
|
|
19
23
|
|
|
20
24
|
plugin_name = File.basename(path)
|
|
21
25
|
dest = File.join(@plugin_dir, plugin_name)
|
|
26
|
+
|
|
27
|
+
# Backup existing plugin if it exists
|
|
28
|
+
backup_existing_plugin(dest) if File.exist?(dest)
|
|
29
|
+
|
|
22
30
|
FileUtils.cp(path, dest)
|
|
23
31
|
load_plugin(dest)
|
|
24
32
|
end
|
|
@@ -48,7 +56,7 @@ module Hedra
|
|
|
48
56
|
def load_plugins
|
|
49
57
|
@plugins = []
|
|
50
58
|
|
|
51
|
-
Dir.glob(File.join(@plugin_dir, '*.rb')).each do |file|
|
|
59
|
+
Dir.glob(File.join(@plugin_dir, '*.rb')).sort.each do |file|
|
|
52
60
|
load_plugin(file)
|
|
53
61
|
end
|
|
54
62
|
end
|
|
@@ -59,6 +67,20 @@ module Hedra
|
|
|
59
67
|
rescue StandardError => e
|
|
60
68
|
warn "Failed to load plugin #{file}: #{e.message}"
|
|
61
69
|
end
|
|
70
|
+
|
|
71
|
+
def validate_plugin_syntax(path)
|
|
72
|
+
code = File.read(path)
|
|
73
|
+
RubyVM::InstructionSequence.compile(code)
|
|
74
|
+
rescue SyntaxError => e
|
|
75
|
+
raise Error, "Plugin has syntax errors: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def backup_existing_plugin(dest)
|
|
79
|
+
backup_path = "#{dest}.backup"
|
|
80
|
+
FileUtils.cp(dest, backup_path)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
warn "Failed to backup existing plugin: #{e.message}"
|
|
83
|
+
end
|
|
62
84
|
end
|
|
63
85
|
|
|
64
86
|
# Base class for plugins
|
|
@@ -23,7 +23,12 @@ module Hedra
|
|
|
23
23
|
|
|
24
24
|
puts "\n"
|
|
25
25
|
elapsed = Time.now - @start_time
|
|
26
|
-
|
|
26
|
+
rate = @total / elapsed
|
|
27
|
+
puts "Completed #{@total} items in #{elapsed.round(2)}s (#{rate.round(2)} items/s)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def percentage
|
|
31
|
+
(@current.to_f / @total * 100).round(1)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -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
|
data/lib/hedra/rate_limiter.rb
CHANGED
|
@@ -51,10 +51,16 @@ module Hedra
|
|
|
51
51
|
def refill_tokens
|
|
52
52
|
now = Time.now
|
|
53
53
|
elapsed = now - @last_refill
|
|
54
|
+
return if elapsed <= 0
|
|
54
55
|
|
|
55
56
|
tokens_to_add = (elapsed / @period) * @requests
|
|
56
57
|
@tokens = [@tokens + tokens_to_add, @requests].min
|
|
57
58
|
@last_refill = now
|
|
58
59
|
end
|
|
60
|
+
|
|
61
|
+
def reset
|
|
62
|
+
@tokens = @requests
|
|
63
|
+
@last_refill = Time.now
|
|
64
|
+
end
|
|
59
65
|
end
|
|
60
66
|
end
|
data/lib/hedra/scorer.rb
CHANGED
|
@@ -23,9 +23,10 @@ module Hedra
|
|
|
23
23
|
def calculate(headers, findings)
|
|
24
24
|
base_score = calculate_base_score(headers)
|
|
25
25
|
penalty = calculate_penalty(findings)
|
|
26
|
+
bonus = calculate_bonus(headers)
|
|
26
27
|
|
|
27
|
-
score = [base_score - penalty, 0].max
|
|
28
|
-
score.round
|
|
28
|
+
score = [base_score - penalty + bonus, 0].max
|
|
29
|
+
[score.round, 100].min # Cap at 100
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
private
|
|
@@ -50,5 +51,26 @@ module Hedra
|
|
|
50
51
|
|
|
51
52
|
penalty
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
def calculate_bonus(headers)
|
|
56
|
+
bonus = 0
|
|
57
|
+
|
|
58
|
+
# Bonus for HSTS with includeSubDomains
|
|
59
|
+
if headers['strict-transport-security']&.include?('includeSubDomains')
|
|
60
|
+
bonus += 2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bonus for HSTS with preload
|
|
64
|
+
if headers['strict-transport-security']&.include?('preload')
|
|
65
|
+
bonus += 3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Bonus for having all recommended headers
|
|
69
|
+
if HEADER_WEIGHTS.keys.all? { |h| headers.key?(h) }
|
|
70
|
+
bonus += 5
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
bonus
|
|
74
|
+
end
|
|
53
75
|
end
|
|
54
76
|
end
|
|
@@ -10,7 +10,12 @@ module Hedra
|
|
|
10
10
|
|
|
11
11
|
def check(url, http_client)
|
|
12
12
|
uri = URI.parse(url)
|
|
13
|
-
|
|
13
|
+
port_part = if uri.port && ![80, 443].include?(uri.port)
|
|
14
|
+
":#{uri.port}"
|
|
15
|
+
else
|
|
16
|
+
''
|
|
17
|
+
end
|
|
18
|
+
base_url = "#{uri.scheme}://#{uri.host}#{port_part}"
|
|
14
19
|
|
|
15
20
|
findings = []
|
|
16
21
|
found = false
|
|
@@ -19,7 +24,9 @@ module Hedra
|
|
|
19
24
|
response = http_client.get("#{base_url}#{path}")
|
|
20
25
|
if response.status.success?
|
|
21
26
|
found = true
|
|
22
|
-
|
|
27
|
+
content = response.body.to_s
|
|
28
|
+
findings.concat(validate_security_txt(content))
|
|
29
|
+
findings.concat(check_signed(content))
|
|
23
30
|
break
|
|
24
31
|
end
|
|
25
32
|
rescue StandardError
|
|
@@ -43,6 +50,22 @@ module Hedra
|
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
53
|
+
def check_signed(content)
|
|
54
|
+
findings = []
|
|
55
|
+
|
|
56
|
+
# Check if security.txt is signed (PGP signature)
|
|
57
|
+
unless content.include?('-----BEGIN PGP SIGNATURE-----')
|
|
58
|
+
findings << {
|
|
59
|
+
header: 'security.txt',
|
|
60
|
+
issue: 'security.txt is not digitally signed',
|
|
61
|
+
severity: :info,
|
|
62
|
+
recommended_fix: 'Consider signing security.txt with PGP for authenticity'
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
findings
|
|
67
|
+
end
|
|
68
|
+
|
|
46
69
|
def validate_security_txt(content)
|
|
47
70
|
findings = []
|
|
48
71
|
required_fields = %w[Contact]
|