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