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.
data/lib/hedra/config.rb CHANGED
@@ -14,17 +14,26 @@ module Hedra
14
14
  'follow_redirects' => true,
15
15
  'user_agent' => "Hedra/#{VERSION}",
16
16
  'proxy' => nil,
17
- 'output_format' => 'table'
17
+ 'output_format' => 'table',
18
+ 'cache_enabled' => false,
19
+ 'cache_ttl' => 3600,
20
+ 'check_certificates' => true,
21
+ 'check_security_txt' => false,
22
+ 'max_retries' => 3
18
23
  }.freeze
19
24
 
20
25
  def self.load
21
26
  ensure_config_dir
22
27
 
23
28
  if File.exist?(CONFIG_FILE)
24
- YAML.load_file(CONFIG_FILE)
29
+ config = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [Symbol])
30
+ DEFAULT_CONFIG.merge(config || {})
25
31
  else
26
32
  DEFAULT_CONFIG
27
33
  end
34
+ rescue Psych::SyntaxError => e
35
+ warn "Invalid YAML in config file: #{e.message}"
36
+ DEFAULT_CONFIG
28
37
  rescue StandardError => e
29
38
  warn "Failed to load config: #{e.message}"
30
39
  DEFAULT_CONFIG
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hedra
4
+ # Validate CORS (Cross-Origin Resource Sharing) headers for security misconfigurations
5
+ class CorsChecker
6
+ DANGEROUS_ORIGINS = [
7
+ '*',
8
+ 'null'
9
+ ].freeze
10
+
11
+ def check(headers, url = nil)
12
+ findings = []
13
+
14
+ # Check Access-Control-Allow-Origin
15
+ if headers.key?('access-control-allow-origin')
16
+ acao = headers['access-control-allow-origin']
17
+ findings.concat(check_allow_origin(acao, headers))
18
+ end
19
+
20
+ # Check Access-Control-Allow-Credentials with wildcard
21
+ if headers.key?('access-control-allow-credentials')
22
+ findings.concat(check_credentials(headers))
23
+ end
24
+
25
+ # Check Access-Control-Allow-Methods
26
+ if headers.key?('access-control-allow-methods')
27
+ findings.concat(check_allow_methods(headers['access-control-allow-methods']))
28
+ end
29
+
30
+ # Check Access-Control-Allow-Headers
31
+ if headers.key?('access-control-allow-headers')
32
+ findings.concat(check_allow_headers(headers['access-control-allow-headers']))
33
+ end
34
+
35
+ # Check Access-Control-Max-Age
36
+ if headers.key?('access-control-max-age')
37
+ findings.concat(check_max_age(headers['access-control-max-age']))
38
+ end
39
+
40
+ # Check for missing CORS headers when others are present
41
+ if cors_headers_present?(headers) && !headers.key?('access-control-allow-origin')
42
+ findings << {
43
+ header: 'access-control-allow-origin',
44
+ issue: 'CORS headers present but Access-Control-Allow-Origin is missing',
45
+ severity: :warning,
46
+ recommended_fix: 'Add Access-Control-Allow-Origin header or remove other CORS headers'
47
+ }
48
+ end
49
+
50
+ findings
51
+ rescue StandardError => e
52
+ warn "CORS check failed: #{e.message}" if ENV['DEBUG']
53
+ []
54
+ end
55
+
56
+ private
57
+
58
+ def check_allow_origin(acao, headers)
59
+ findings = []
60
+
61
+ # Wildcard with credentials is a critical security issue
62
+ if acao == '*' && credentials_enabled?(headers)
63
+ findings << {
64
+ header: 'access-control-allow-origin',
65
+ issue: 'CORS allows all origins (*) with credentials enabled - critical security risk',
66
+ severity: :critical,
67
+ recommended_fix: 'Use specific origin instead of wildcard when credentials are enabled'
68
+ }
69
+ elsif acao == '*'
70
+ findings << {
71
+ header: 'access-control-allow-origin',
72
+ issue: 'CORS allows all origins (*) - potential security risk',
73
+ severity: :warning,
74
+ recommended_fix: 'Restrict to specific trusted origins'
75
+ }
76
+ elsif acao == 'null'
77
+ findings << {
78
+ header: 'access-control-allow-origin',
79
+ issue: 'CORS allows "null" origin - security vulnerability',
80
+ severity: :critical,
81
+ recommended_fix: 'Never allow "null" origin, use specific origins'
82
+ }
83
+ elsif acao.include?(',')
84
+ findings << {
85
+ header: 'access-control-allow-origin',
86
+ issue: 'Multiple origins in Access-Control-Allow-Origin (invalid syntax)',
87
+ severity: :critical,
88
+ recommended_fix: 'Use single origin or implement dynamic origin validation'
89
+ }
90
+ elsif acao.match?(%r{^https?://})
91
+ # Valid origin, check for common issues
92
+ if acao.start_with?('http://') && !acao.include?('localhost') && !acao.include?('127.0.0.1')
93
+ findings << {
94
+ header: 'access-control-allow-origin',
95
+ issue: 'CORS allows insecure HTTP origin',
96
+ severity: :warning,
97
+ recommended_fix: 'Use HTTPS origins only'
98
+ }
99
+ end
100
+ end
101
+
102
+ findings
103
+ end
104
+
105
+ def check_credentials(headers)
106
+ findings = []
107
+ acac = headers['access-control-allow-credentials']
108
+
109
+ if acac.to_s.downcase == 'true'
110
+ acao = headers['access-control-allow-origin']
111
+
112
+ if acao == '*'
113
+ findings << {
114
+ header: 'access-control-allow-credentials',
115
+ issue: 'Credentials enabled with wildcard origin (invalid and dangerous)',
116
+ severity: :critical,
117
+ recommended_fix: 'Use specific origin when credentials are enabled'
118
+ }
119
+ elsif acao.nil?
120
+ findings << {
121
+ header: 'access-control-allow-credentials',
122
+ issue: 'Credentials enabled without Access-Control-Allow-Origin',
123
+ severity: :warning,
124
+ recommended_fix: 'Add Access-Control-Allow-Origin header'
125
+ }
126
+ end
127
+ elsif acac && acac.to_s.downcase != 'true'
128
+ findings << {
129
+ header: 'access-control-allow-credentials',
130
+ issue: 'Invalid Access-Control-Allow-Credentials value (must be "true" or omitted)',
131
+ severity: :warning,
132
+ recommended_fix: 'Set to "true" or remove the header'
133
+ }
134
+ end
135
+
136
+ findings
137
+ end
138
+
139
+ def check_allow_methods(methods)
140
+ findings = []
141
+ method_list = methods.to_s.upcase.split(',').map(&:strip)
142
+
143
+ # Check for overly permissive methods
144
+ dangerous_methods = ['TRACE', 'TRACK', 'CONNECT']
145
+ found_dangerous = method_list & dangerous_methods
146
+
147
+ if found_dangerous.any?
148
+ findings << {
149
+ header: 'access-control-allow-methods',
150
+ issue: "Dangerous HTTP methods allowed: #{found_dangerous.join(', ')}",
151
+ severity: :critical,
152
+ recommended_fix: 'Remove dangerous methods (TRACE, TRACK, CONNECT)'
153
+ }
154
+ end
155
+
156
+ # Check for wildcard
157
+ if method_list.include?('*')
158
+ findings << {
159
+ header: 'access-control-allow-methods',
160
+ issue: 'CORS allows all HTTP methods (*)',
161
+ severity: :warning,
162
+ recommended_fix: 'Specify only required methods (GET, POST, etc.)'
163
+ }
164
+ end
165
+
166
+ # Check for overly permissive DELETE/PUT without proper consideration
167
+ risky_methods = ['DELETE', 'PUT', 'PATCH']
168
+ found_risky = method_list & risky_methods
169
+
170
+ if found_risky.any? && method_list.length > 5
171
+ findings << {
172
+ header: 'access-control-allow-methods',
173
+ issue: "Potentially risky methods allowed: #{found_risky.join(', ')}",
174
+ severity: :info,
175
+ recommended_fix: 'Ensure write methods are properly protected with authentication'
176
+ }
177
+ end
178
+
179
+ findings
180
+ end
181
+
182
+ def check_allow_headers(headers_value)
183
+ findings = []
184
+ header_list = headers_value.to_s.downcase.split(',').map(&:strip)
185
+
186
+ # Check for wildcard
187
+ if header_list.include?('*')
188
+ findings << {
189
+ header: 'access-control-allow-headers',
190
+ issue: 'CORS allows all headers (*)',
191
+ severity: :warning,
192
+ recommended_fix: 'Specify only required headers'
193
+ }
194
+ end
195
+
196
+ # Check for sensitive headers
197
+ sensitive_headers = ['authorization', 'cookie', 'x-api-key', 'x-auth-token']
198
+ found_sensitive = header_list & sensitive_headers
199
+
200
+ if found_sensitive.any?
201
+ findings << {
202
+ header: 'access-control-allow-headers',
203
+ issue: "Sensitive headers exposed: #{found_sensitive.join(', ')}",
204
+ severity: :info,
205
+ recommended_fix: 'Ensure sensitive headers are only allowed for trusted origins'
206
+ }
207
+ end
208
+
209
+ findings
210
+ end
211
+
212
+ def check_max_age(max_age)
213
+ findings = []
214
+ age = max_age.to_i
215
+
216
+ if age <= 0
217
+ findings << {
218
+ header: 'access-control-max-age',
219
+ issue: 'Invalid Access-Control-Max-Age value',
220
+ severity: :info,
221
+ recommended_fix: 'Set to positive number of seconds (e.g., 3600)'
222
+ }
223
+ elsif age > 86400
224
+ findings << {
225
+ header: 'access-control-max-age',
226
+ issue: 'Very long preflight cache duration (>24 hours)',
227
+ severity: :info,
228
+ recommended_fix: 'Consider shorter duration for security updates'
229
+ }
230
+ end
231
+
232
+ findings
233
+ end
234
+
235
+ def credentials_enabled?(headers)
236
+ headers['access-control-allow-credentials'].to_s.downcase == 'true'
237
+ end
238
+
239
+ def cors_headers_present?(headers)
240
+ cors_header_keys = [
241
+ 'access-control-allow-credentials',
242
+ 'access-control-allow-methods',
243
+ 'access-control-allow-headers',
244
+ 'access-control-max-age',
245
+ 'access-control-expose-headers'
246
+ ]
247
+
248
+ cors_header_keys.any? { |key| headers.key?(key) }
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'socket'
5
+ require 'uri'
6
+
7
+ module Hedra
8
+ # Check Certificate Transparency (CT) logs
9
+ class CtChecker
10
+ # CT Precertificate SCTs extension OID
11
+ CT_PRECERT_SCTS_OID = '1.3.6.1.4.1.11129.2.4.2'
12
+
13
+ # SCT version
14
+ SCT_VERSION_V1 = 0
15
+
16
+ # Minimum recommended SCT count
17
+ MIN_SCT_COUNT = 2
18
+
19
+ # SCT (Signed Certificate Timestamp) sources
20
+ SCT_SOURCES = {
21
+ extension: 'X509v3 extension',
22
+ ocsp: 'OCSP stapling',
23
+ tls: 'TLS extension'
24
+ }.freeze
25
+
26
+ def check(url)
27
+ findings = []
28
+ uri = URI.parse(url)
29
+
30
+ return findings unless uri.scheme == 'https'
31
+
32
+ ct_info = check_certificate_transparency(uri.host, uri.port || 443)
33
+
34
+ if ct_info[:scts].empty?
35
+ findings << {
36
+ header: 'certificate-transparency',
37
+ issue: 'Certificate not logged in Certificate Transparency logs (no SCTs found)',
38
+ severity: :warning,
39
+ recommended_fix: 'Use a CA that supports Certificate Transparency or ensure CT logging is enabled'
40
+ }
41
+ else
42
+ # Check number of SCTs
43
+ sct_count = ct_info[:scts].length
44
+ if sct_count < MIN_SCT_COUNT
45
+ findings << {
46
+ header: 'certificate-transparency',
47
+ issue: "Only #{sct_count} SCT found - recommend at least #{MIN_SCT_COUNT} from different logs",
48
+ severity: :info,
49
+ recommended_fix: 'Ensure certificate has SCTs from multiple independent CT logs for redundancy'
50
+ }
51
+ end
52
+
53
+ # Check SCT sources diversity
54
+ sources = ct_info[:scts].map { |sct| sct[:source] }.uniq
55
+ if sources.length == 1 && sources.first == :extension
56
+ findings << {
57
+ header: 'certificate-transparency',
58
+ issue: 'All SCTs embedded in certificate only',
59
+ severity: :info,
60
+ recommended_fix: 'Consider adding SCTs via OCSP stapling or TLS extension for better privacy'
61
+ }
62
+ end
63
+
64
+ # Check for valid SCT versions
65
+ invalid_versions = ct_info[:scts].select { |sct| sct[:version] && sct[:version] != SCT_VERSION_V1 }
66
+ if invalid_versions.any?
67
+ findings << {
68
+ header: 'certificate-transparency',
69
+ issue: "Found SCTs with unsupported versions: #{invalid_versions.map { |s| s[:version] }.uniq.join(', ')}",
70
+ severity: :warning,
71
+ recommended_fix: 'Ensure all SCTs use version 1 (v1)'
72
+ }
73
+ end
74
+ end
75
+
76
+ findings
77
+ rescue StandardError => e
78
+ warn "Certificate Transparency check failed: #{e.message}" if ENV['DEBUG']
79
+ warn e.backtrace.join("\n") if ENV['DEBUG']
80
+ []
81
+ end
82
+
83
+ private
84
+
85
+ def check_certificate_transparency(host, port)
86
+ result = {
87
+ scts: [],
88
+ ct_enabled: false
89
+ }
90
+
91
+ tcp_socket = nil
92
+ ssl_socket = nil
93
+
94
+ begin
95
+ tcp_socket = Socket.tcp(host, port, connect_timeout: 5)
96
+ ssl_context = OpenSSL::SSL::SSLContext.new
97
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
98
+
99
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
100
+ ssl_socket.sync_close = true
101
+ ssl_socket.connect
102
+
103
+ cert = ssl_socket.peer_cert
104
+
105
+ # Check for SCT in certificate extensions
106
+ scts_from_cert = extract_scts_from_certificate(cert)
107
+ result[:scts].concat(scts_from_cert)
108
+
109
+ # Check for SCT in OCSP response (if stapled)
110
+ scts_from_ocsp = extract_scts_from_ocsp(ssl_socket)
111
+ result[:scts].concat(scts_from_ocsp)
112
+
113
+ result[:ct_enabled] = result[:scts].any?
114
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH, SocketError => e
115
+ warn "CT check connection error for #{host}:#{port}: #{e.message}" if ENV['DEBUG']
116
+ rescue OpenSSL::SSL::SSLError => e
117
+ warn "CT check SSL error for #{host}:#{port}: #{e.message}" if ENV['DEBUG']
118
+ rescue StandardError => e
119
+ warn "CT check error for #{host}:#{port}: #{e.class} - #{e.message}" if ENV['DEBUG']
120
+ ensure
121
+ ssl_socket&.close rescue nil
122
+ tcp_socket&.close rescue nil
123
+ end
124
+
125
+ result
126
+ end
127
+
128
+ def extract_scts_from_certificate(cert)
129
+ scts = []
130
+
131
+ # Look for CT Precertificate SCTs extension (OID 1.3.6.1.4.1.11129.2.4.2)
132
+ ct_extension = cert.extensions.find { |ext| ext.oid == CT_PRECERT_SCTS_OID }
133
+
134
+ return scts unless ct_extension
135
+
136
+ begin
137
+ # The extension value is DER-encoded OCTET STRING containing SCT list
138
+ # Parse the SCT list structure
139
+ parsed_scts = parse_sct_list(ct_extension.value)
140
+
141
+ parsed_scts.each_with_index do |sct_data, index|
142
+ scts << {
143
+ source: :extension,
144
+ index: index,
145
+ version: sct_data[:version],
146
+ log_id: sct_data[:log_id],
147
+ timestamp: sct_data[:timestamp],
148
+ valid: sct_data[:valid]
149
+ }
150
+ end
151
+ rescue StandardError => e
152
+ warn "Failed to parse SCTs from certificate: #{e.message}" if ENV['DEBUG']
153
+
154
+ # Fallback: estimate SCT count from extension size
155
+ sct_count = estimate_sct_count_from_size(ct_extension.value)
156
+ sct_count.times do |i|
157
+ scts << {
158
+ source: :extension,
159
+ index: i,
160
+ version: nil,
161
+ log_id: nil,
162
+ timestamp: nil,
163
+ valid: true
164
+ }
165
+ end
166
+ end
167
+
168
+ scts
169
+ end
170
+
171
+ def extract_scts_from_ocsp(ssl_socket)
172
+ # OCSP stapling support in Ruby's OpenSSL binding is limited
173
+ # This would require accessing the OCSP response from the TLS handshake
174
+ # For now, return empty array as this requires low-level TLS access
175
+ []
176
+ rescue StandardError => e
177
+ warn "Failed to extract SCTs from OCSP: #{e.message}" if ENV['DEBUG']
178
+ []
179
+ end
180
+
181
+ def parse_sct_list(der_data)
182
+ scts = []
183
+
184
+ begin
185
+ # The extension value is an OCTET STRING containing the SCT list
186
+ # First, decode the outer OCTET STRING wrapper
187
+ asn1 = OpenSSL::ASN1.decode(der_data)
188
+
189
+ # Get the actual SCT list data
190
+ sct_list_data = if asn1.tag == OpenSSL::ASN1::OCTET_STRING
191
+ asn1.value
192
+ else
193
+ der_data
194
+ end
195
+
196
+ # Parse SCT list structure
197
+ # Format: 2-byte length prefix, then concatenated SCTs
198
+ return scts if sct_list_data.bytesize < 2
199
+
200
+ list_length = sct_list_data.unpack1('n') # Read 2-byte big-endian length
201
+ offset = 2
202
+
203
+ # Parse individual SCTs
204
+ while offset < sct_list_data.bytesize && offset < list_length + 2
205
+ sct_data = parse_single_sct(sct_list_data, offset)
206
+ break unless sct_data
207
+
208
+ scts << sct_data[:sct]
209
+ offset = sct_data[:next_offset]
210
+ end
211
+ rescue OpenSSL::ASN1::ASN1Error => e
212
+ warn "ASN.1 parsing error: #{e.message}" if ENV['DEBUG']
213
+ rescue StandardError => e
214
+ warn "SCT list parsing error: #{e.message}" if ENV['DEBUG']
215
+ end
216
+
217
+ scts
218
+ end
219
+
220
+ def parse_single_sct(data, offset)
221
+ # SCT structure:
222
+ # - 2 bytes: SCT length
223
+ # - 1 byte: version
224
+ # - 32 bytes: log_id
225
+ # - 8 bytes: timestamp
226
+ # - 2 bytes: extensions length
227
+ # - N bytes: extensions
228
+ # - 2 bytes: signature algorithm
229
+ # - 2 bytes: signature length
230
+ # - M bytes: signature
231
+
232
+ return nil if offset + 2 > data.bytesize
233
+
234
+ sct_length = data[offset, 2].unpack1('n')
235
+ sct_start = offset + 2
236
+ sct_end = sct_start + sct_length
237
+
238
+ return nil if sct_end > data.bytesize
239
+
240
+ sct_bytes = data[sct_start...sct_end]
241
+
242
+ # Parse SCT fields
243
+ return nil if sct_bytes.bytesize < 43 # Minimum: version(1) + log_id(32) + timestamp(8) + ext_len(2)
244
+
245
+ version = sct_bytes[0].unpack1('C')
246
+ log_id = sct_bytes[1, 32].unpack1('H*')
247
+ timestamp = sct_bytes[33, 8].unpack1('Q>')
248
+
249
+ {
250
+ sct: {
251
+ version: version,
252
+ log_id: log_id,
253
+ timestamp: timestamp,
254
+ valid: version == SCT_VERSION_V1
255
+ },
256
+ next_offset: sct_end
257
+ }
258
+ rescue StandardError => e
259
+ warn "Single SCT parsing error: #{e.message}" if ENV['DEBUG']
260
+ nil
261
+ end
262
+
263
+ def estimate_sct_count_from_size(extension_value)
264
+ # Fallback estimation when parsing fails
265
+ # SCTs are typically 100-150 bytes each
266
+ return 0 if extension_value.nil? || extension_value.empty?
267
+
268
+ # Try to decode as OCTET STRING first
269
+ begin
270
+ asn1 = OpenSSL::ASN1.decode(extension_value)
271
+ data = asn1.value if asn1.tag == OpenSSL::ASN1::OCTET_STRING
272
+ rescue StandardError
273
+ data = extension_value
274
+ end
275
+
276
+ data_size = data.bytesize
277
+
278
+ # Estimate: each SCT is roughly 120 bytes (conservative estimate)
279
+ # Minimum 1, maximum 5 for safety
280
+ estimated = [data_size / 120, 1].max
281
+ [estimated, 5].min
282
+ rescue StandardError
283
+ 1 # Default to 1 if estimation fails
284
+ end
285
+ end
286
+ end