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