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,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
@@ -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