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.
@@ -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
@@ -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
- puts "Completed #{@total} items in #{elapsed.round(2)}s"
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
@@ -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
- base_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port && ![80, 443].include?(uri.port)}"
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
- findings.concat(validate_security_txt(response.body.to_s))
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]