ai_root_shield 0.3.0 → 0.4.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/CHANGELOG.md +25 -0
- data/Gemfile.lock +1 -1
- data/README.md +58 -6
- data/examples/policies/banking_policy.json +79 -0
- data/examples/policies/development_policy.json +64 -0
- data/examples/policies/enterprise_policy.json +89 -0
- data/exe/ai_root_shield +95 -2
- data/lib/ai_root_shield/advanced_proxy_detector.rb +406 -0
- data/lib/ai_root_shield/certificate_pinning_helper.rb +258 -0
- data/lib/ai_root_shield/enterprise_policy_manager.rb +431 -0
- data/lib/ai_root_shield/version.rb +1 -1
- data/lib/ai_root_shield.rb +139 -4
- metadata +15 -5
@@ -0,0 +1,406 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "resolv"
|
4
|
+
require "net/http"
|
5
|
+
require "ipaddr"
|
6
|
+
|
7
|
+
module AiRootShield
|
8
|
+
# Advanced proxy detection for VPN, Tor, custom DNS, and MITM appliances
|
9
|
+
class AdvancedProxyDetector
|
10
|
+
# Known Tor exit node IP ranges and identifiers
|
11
|
+
TOR_INDICATORS = {
|
12
|
+
dns_names: [
|
13
|
+
"tor-exit", "torservers", "artikel5ev", "privacyfoundation",
|
14
|
+
"torproject", "exitnode", "tor-relay"
|
15
|
+
],
|
16
|
+
asn_patterns: [
|
17
|
+
/tor/i, /privacy/i, /anonymous/i, /vpn/i
|
18
|
+
]
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
# Common VPN provider indicators
|
22
|
+
VPN_INDICATORS = {
|
23
|
+
dns_patterns: [
|
24
|
+
/vpn/i, /proxy/i, /tunnel/i, /shield/i, /secure/i,
|
25
|
+
/private/i, /anonymous/i, /hide/i, /mask/i
|
26
|
+
],
|
27
|
+
asn_patterns: [
|
28
|
+
/vpn/i, /virtual private/i, /proxy/i, /datacenter/i,
|
29
|
+
/hosting/i, /cloud/i, /server/i
|
30
|
+
],
|
31
|
+
known_providers: [
|
32
|
+
"nordvpn", "expressvpn", "surfshark", "cyberghost", "pia",
|
33
|
+
"mullvad", "protonvpn", "windscribe", "tunnelbear", "hotspotshield"
|
34
|
+
]
|
35
|
+
}.freeze
|
36
|
+
|
37
|
+
# MITM appliance indicators
|
38
|
+
MITM_INDICATORS = {
|
39
|
+
certificate_issuers: [
|
40
|
+
/blue coat/i, /websense/i, /forcepoint/i, /zscaler/i,
|
41
|
+
/checkpoint/i, /palo alto/i, /fortinet/i, /sophos/i,
|
42
|
+
/mcafee/i, /symantec/i, /broadcom/i
|
43
|
+
],
|
44
|
+
proxy_headers: [
|
45
|
+
"x-bluecoat-via", "x-forwarded-for", "x-real-ip",
|
46
|
+
"x-proxy-id", "x-cache", "via", "x-forwarded-proto"
|
47
|
+
]
|
48
|
+
}.freeze
|
49
|
+
|
50
|
+
# Custom DNS server indicators
|
51
|
+
CUSTOM_DNS_INDICATORS = {
|
52
|
+
public_dns: [
|
53
|
+
"8.8.8.8", "8.8.4.4", # Google
|
54
|
+
"1.1.1.1", "1.0.0.1", # Cloudflare
|
55
|
+
"208.67.222.222", "208.67.220.220", # OpenDNS
|
56
|
+
"9.9.9.9", "149.112.112.112" # Quad9
|
57
|
+
],
|
58
|
+
privacy_dns: [
|
59
|
+
"94.140.14.14", "94.140.15.15", # AdGuard
|
60
|
+
"76.76.19.19", "76.223.100.101", # Alternate DNS
|
61
|
+
"185.228.168.9", "185.228.169.9" # CleanBrowsing
|
62
|
+
]
|
63
|
+
}.freeze
|
64
|
+
|
65
|
+
def initialize(config = {})
|
66
|
+
@config = {
|
67
|
+
enable_tor_detection: true,
|
68
|
+
enable_vpn_detection: true,
|
69
|
+
enable_mitm_detection: true,
|
70
|
+
enable_dns_detection: true,
|
71
|
+
timeout: 5,
|
72
|
+
max_retries: 2,
|
73
|
+
use_external_apis: false
|
74
|
+
}.merge(config)
|
75
|
+
|
76
|
+
@detection_cache = {}
|
77
|
+
@last_detection_time = {}
|
78
|
+
end
|
79
|
+
|
80
|
+
# Perform comprehensive proxy detection
|
81
|
+
# @param target_ip [String] IP address to analyze
|
82
|
+
# @param additional_data [Hash] Additional network data
|
83
|
+
# @return [Hash] Detection results
|
84
|
+
def detect_proxy(target_ip, additional_data = {})
|
85
|
+
return cached_result(target_ip) if cached_and_fresh?(target_ip)
|
86
|
+
|
87
|
+
results = {
|
88
|
+
ip_address: target_ip,
|
89
|
+
timestamp: Time.now.to_f,
|
90
|
+
proxy_detected: false,
|
91
|
+
proxy_types: [],
|
92
|
+
confidence_score: 0.0,
|
93
|
+
indicators: [],
|
94
|
+
details: {}
|
95
|
+
}
|
96
|
+
|
97
|
+
# Perform different detection methods
|
98
|
+
results = detect_tor_exit_node(target_ip, results) if @config[:enable_tor_detection]
|
99
|
+
results = detect_vpn_service(target_ip, results) if @config[:enable_vpn_detection]
|
100
|
+
results = detect_mitm_appliance(target_ip, additional_data, results) if @config[:enable_mitm_detection]
|
101
|
+
results = detect_custom_dns(additional_data, results) if @config[:enable_dns_detection]
|
102
|
+
|
103
|
+
# Calculate overall confidence
|
104
|
+
results[:confidence_score] = calculate_confidence_score(results)
|
105
|
+
results[:proxy_detected] = results[:confidence_score] > 0.5
|
106
|
+
|
107
|
+
# Cache results
|
108
|
+
cache_result(target_ip, results)
|
109
|
+
|
110
|
+
results
|
111
|
+
end
|
112
|
+
|
113
|
+
# Detect Tor exit nodes
|
114
|
+
# @param ip [String] IP address to check
|
115
|
+
# @param results [Hash] Current results hash
|
116
|
+
# @return [Hash] Updated results
|
117
|
+
def detect_tor_exit_node(ip, results)
|
118
|
+
tor_indicators = []
|
119
|
+
|
120
|
+
# Check reverse DNS for Tor indicators
|
121
|
+
begin
|
122
|
+
hostname = Resolv.getname(ip)
|
123
|
+
TOR_INDICATORS[:dns_names].each do |indicator|
|
124
|
+
if hostname.downcase.include?(indicator)
|
125
|
+
tor_indicators << "tor_dns_pattern: #{indicator}"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
rescue Resolv::ResolvError
|
129
|
+
# No reverse DNS available
|
130
|
+
end
|
131
|
+
|
132
|
+
# Check if IP is in known Tor exit node lists (if external APIs enabled)
|
133
|
+
if @config[:use_external_apis]
|
134
|
+
tor_indicators.concat(check_tor_exit_lists(ip))
|
135
|
+
end
|
136
|
+
|
137
|
+
# Check for Tor-specific network characteristics
|
138
|
+
tor_indicators.concat(analyze_tor_network_characteristics(ip))
|
139
|
+
|
140
|
+
unless tor_indicators.empty?
|
141
|
+
results[:proxy_types] << "tor_exit_node"
|
142
|
+
results[:indicators].concat(tor_indicators)
|
143
|
+
results[:details][:tor] = {
|
144
|
+
detected: true,
|
145
|
+
indicators: tor_indicators,
|
146
|
+
risk_level: "high"
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
results
|
151
|
+
end
|
152
|
+
|
153
|
+
# Detect VPN services
|
154
|
+
# @param ip [String] IP address to check
|
155
|
+
# @param results [Hash] Current results hash
|
156
|
+
# @return [Hash] Updated results
|
157
|
+
def detect_vpn_service(ip, results)
|
158
|
+
vpn_indicators = []
|
159
|
+
|
160
|
+
# Check reverse DNS for VPN patterns
|
161
|
+
begin
|
162
|
+
hostname = Resolv.getname(ip)
|
163
|
+
VPN_INDICATORS[:dns_patterns].each do |pattern|
|
164
|
+
if hostname.match?(pattern)
|
165
|
+
vpn_indicators << "vpn_dns_pattern: #{pattern}"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Check for known VPN providers
|
170
|
+
VPN_INDICATORS[:known_providers].each do |provider|
|
171
|
+
if hostname.downcase.include?(provider)
|
172
|
+
vpn_indicators << "known_vpn_provider: #{provider}"
|
173
|
+
end
|
174
|
+
end
|
175
|
+
rescue Resolv::ResolvError
|
176
|
+
# No reverse DNS available
|
177
|
+
end
|
178
|
+
|
179
|
+
# Check ASN information for VPN characteristics
|
180
|
+
vpn_indicators.concat(analyze_asn_for_vpn(ip))
|
181
|
+
|
182
|
+
# Check for datacenter/hosting characteristics
|
183
|
+
vpn_indicators.concat(analyze_hosting_characteristics(ip))
|
184
|
+
|
185
|
+
unless vpn_indicators.empty?
|
186
|
+
results[:proxy_types] << "vpn_service"
|
187
|
+
results[:indicators].concat(vpn_indicators)
|
188
|
+
results[:details][:vpn] = {
|
189
|
+
detected: true,
|
190
|
+
indicators: vpn_indicators,
|
191
|
+
risk_level: "medium"
|
192
|
+
}
|
193
|
+
end
|
194
|
+
|
195
|
+
results
|
196
|
+
end
|
197
|
+
|
198
|
+
# Detect MITM appliances
|
199
|
+
# @param ip [String] IP address to check
|
200
|
+
# @param additional_data [Hash] Additional network data
|
201
|
+
# @param results [Hash] Current results hash
|
202
|
+
# @return [Hash] Updated results
|
203
|
+
def detect_mitm_appliance(ip, additional_data, results)
|
204
|
+
mitm_indicators = []
|
205
|
+
|
206
|
+
# Check for MITM certificate issuers
|
207
|
+
if additional_data[:certificates]
|
208
|
+
additional_data[:certificates].each do |cert_info|
|
209
|
+
issuer = cert_info[:issuer] || ""
|
210
|
+
MITM_INDICATORS[:certificate_issuers].each do |pattern|
|
211
|
+
if issuer.match?(pattern)
|
212
|
+
mitm_indicators << "mitm_certificate_issuer: #{pattern}"
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Check for proxy headers
|
219
|
+
if additional_data[:http_headers]
|
220
|
+
MITM_INDICATORS[:proxy_headers].each do |header|
|
221
|
+
if additional_data[:http_headers].key?(header.downcase)
|
222
|
+
mitm_indicators << "proxy_header_detected: #{header}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Check for transparent proxy characteristics
|
228
|
+
mitm_indicators.concat(detect_transparent_proxy(ip, additional_data))
|
229
|
+
|
230
|
+
unless mitm_indicators.empty?
|
231
|
+
results[:proxy_types] << "mitm_appliance"
|
232
|
+
results[:indicators].concat(mitm_indicators)
|
233
|
+
results[:details][:mitm] = {
|
234
|
+
detected: true,
|
235
|
+
indicators: mitm_indicators,
|
236
|
+
risk_level: "high"
|
237
|
+
}
|
238
|
+
end
|
239
|
+
|
240
|
+
results
|
241
|
+
end
|
242
|
+
|
243
|
+
# Detect custom DNS configuration
|
244
|
+
# @param additional_data [Hash] Additional network data
|
245
|
+
# @param results [Hash] Current results hash
|
246
|
+
# @return [Hash] Updated results
|
247
|
+
def detect_custom_dns(additional_data, results)
|
248
|
+
dns_indicators = []
|
249
|
+
|
250
|
+
# Check configured DNS servers
|
251
|
+
if additional_data[:dns_servers]
|
252
|
+
dns_servers = Array(additional_data[:dns_servers])
|
253
|
+
|
254
|
+
dns_servers.each do |dns_server|
|
255
|
+
# Check for public DNS services
|
256
|
+
if CUSTOM_DNS_INDICATORS[:public_dns].include?(dns_server)
|
257
|
+
dns_indicators << "public_dns_detected: #{dns_server}"
|
258
|
+
elsif CUSTOM_DNS_INDICATORS[:privacy_dns].include?(dns_server)
|
259
|
+
dns_indicators << "privacy_dns_detected: #{dns_server}"
|
260
|
+
elsif !is_isp_dns?(dns_server)
|
261
|
+
dns_indicators << "custom_dns_detected: #{dns_server}"
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Check for DNS over HTTPS/TLS usage
|
267
|
+
if additional_data[:doh_enabled] || additional_data[:dot_enabled]
|
268
|
+
dns_indicators << "encrypted_dns_detected"
|
269
|
+
end
|
270
|
+
|
271
|
+
unless dns_indicators.empty?
|
272
|
+
results[:proxy_types] << "custom_dns"
|
273
|
+
results[:indicators].concat(dns_indicators)
|
274
|
+
results[:details][:dns] = {
|
275
|
+
detected: true,
|
276
|
+
indicators: dns_indicators,
|
277
|
+
risk_level: "low"
|
278
|
+
}
|
279
|
+
end
|
280
|
+
|
281
|
+
results
|
282
|
+
end
|
283
|
+
|
284
|
+
# Get detection statistics
|
285
|
+
# @return [Hash] Detection statistics
|
286
|
+
def detection_statistics
|
287
|
+
{
|
288
|
+
total_detections: @detection_cache.size,
|
289
|
+
cache_hits: @detection_cache.values.count { |v| v[:cached] },
|
290
|
+
detection_types: @detection_cache.values.flat_map { |v| v[:proxy_types] }.tally,
|
291
|
+
average_confidence: calculate_average_confidence,
|
292
|
+
last_detection: @last_detection_time.values.max
|
293
|
+
}
|
294
|
+
end
|
295
|
+
|
296
|
+
# Clear detection cache
|
297
|
+
def clear_cache
|
298
|
+
@detection_cache.clear
|
299
|
+
@last_detection_time.clear
|
300
|
+
end
|
301
|
+
|
302
|
+
private
|
303
|
+
|
304
|
+
def cached_and_fresh?(ip, ttl = 300)
|
305
|
+
return false unless @detection_cache[ip]
|
306
|
+
return false unless @last_detection_time[ip]
|
307
|
+
|
308
|
+
Time.now.to_f - @last_detection_time[ip] < ttl
|
309
|
+
end
|
310
|
+
|
311
|
+
def cached_result(ip)
|
312
|
+
result = @detection_cache[ip].dup
|
313
|
+
result[:cached] = true
|
314
|
+
result
|
315
|
+
end
|
316
|
+
|
317
|
+
def cache_result(ip, results)
|
318
|
+
@detection_cache[ip] = results
|
319
|
+
@last_detection_time[ip] = Time.now.to_f
|
320
|
+
end
|
321
|
+
|
322
|
+
def calculate_confidence_score(results)
|
323
|
+
base_score = 0.0
|
324
|
+
|
325
|
+
# Weight different proxy types
|
326
|
+
type_weights = {
|
327
|
+
"tor_exit_node" => 0.9,
|
328
|
+
"mitm_appliance" => 0.8,
|
329
|
+
"vpn_service" => 0.6,
|
330
|
+
"custom_dns" => 0.3
|
331
|
+
}
|
332
|
+
|
333
|
+
results[:proxy_types].each do |type|
|
334
|
+
base_score += type_weights[type] || 0.5
|
335
|
+
end
|
336
|
+
|
337
|
+
# Factor in number of indicators
|
338
|
+
indicator_bonus = [results[:indicators].size * 0.1, 0.3].min
|
339
|
+
|
340
|
+
# Cap at 1.0
|
341
|
+
[base_score + indicator_bonus, 1.0].min
|
342
|
+
end
|
343
|
+
|
344
|
+
def check_tor_exit_lists(ip)
|
345
|
+
# Placeholder for external Tor exit node list checking
|
346
|
+
# In production, this would query Tor directory authorities
|
347
|
+
[]
|
348
|
+
end
|
349
|
+
|
350
|
+
def analyze_tor_network_characteristics(ip)
|
351
|
+
indicators = []
|
352
|
+
|
353
|
+
# Check for common Tor exit node ports
|
354
|
+
common_tor_ports = [80, 443, 993, 995]
|
355
|
+
# This would require actual port scanning in production
|
356
|
+
|
357
|
+
indicators
|
358
|
+
end
|
359
|
+
|
360
|
+
def analyze_asn_for_vpn(ip)
|
361
|
+
indicators = []
|
362
|
+
|
363
|
+
# This would require ASN lookup in production
|
364
|
+
# For now, return empty array
|
365
|
+
|
366
|
+
indicators
|
367
|
+
end
|
368
|
+
|
369
|
+
def analyze_hosting_characteristics(ip)
|
370
|
+
indicators = []
|
371
|
+
|
372
|
+
# Check if IP is in known datacenter ranges
|
373
|
+
# This would require GeoIP database in production
|
374
|
+
|
375
|
+
indicators
|
376
|
+
end
|
377
|
+
|
378
|
+
def detect_transparent_proxy(ip, additional_data)
|
379
|
+
indicators = []
|
380
|
+
|
381
|
+
# Check for transparent proxy indicators
|
382
|
+
if additional_data[:tcp_timestamp_anomaly]
|
383
|
+
indicators << "tcp_timestamp_anomaly"
|
384
|
+
end
|
385
|
+
|
386
|
+
if additional_data[:ttl_anomaly]
|
387
|
+
indicators << "ttl_hop_anomaly"
|
388
|
+
end
|
389
|
+
|
390
|
+
indicators
|
391
|
+
end
|
392
|
+
|
393
|
+
def is_isp_dns?(dns_server)
|
394
|
+
# Check if DNS server belongs to common ISP ranges
|
395
|
+
# This would require ISP database lookup in production
|
396
|
+
false
|
397
|
+
end
|
398
|
+
|
399
|
+
def calculate_average_confidence
|
400
|
+
return 0.0 if @detection_cache.empty?
|
401
|
+
|
402
|
+
total_confidence = @detection_cache.values.sum { |v| v[:confidence_score] }
|
403
|
+
total_confidence / @detection_cache.size
|
404
|
+
end
|
405
|
+
end
|
406
|
+
end
|
@@ -0,0 +1,258 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "net/http"
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
module AiRootShield
|
8
|
+
# Certificate pinning helper for TLS public key pinning integration
|
9
|
+
class CertificatePinningHelper
|
10
|
+
# Supported hash algorithms for pinning
|
11
|
+
SUPPORTED_ALGORITHMS = %w[sha256 sha1].freeze
|
12
|
+
|
13
|
+
# Common certificate authorities and their pins
|
14
|
+
COMMON_CA_PINS = {
|
15
|
+
"letsencrypt" => [
|
16
|
+
"sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", # ISRG Root X1
|
17
|
+
"sha256/sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=" # ISRG Root X2
|
18
|
+
],
|
19
|
+
"digicert" => [
|
20
|
+
"sha256/WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", # DigiCert Global Root G2
|
21
|
+
"sha256/RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho=" # DigiCert Global Root CA
|
22
|
+
],
|
23
|
+
"google" => [
|
24
|
+
"sha256/KwccWaCgrnaw6tsrrSO61FgLacNgG2MMLq8GE6+oP5I=", # GTS Root R1
|
25
|
+
"sha256/FEzVOUp4dF3gI0ZVPRJhFbSD608T5Wx5Bp0+jBw/gQo=" # GTS Root R2
|
26
|
+
]
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
def initialize(config = {})
|
30
|
+
@config = {
|
31
|
+
algorithm: "sha256",
|
32
|
+
backup_pins: [],
|
33
|
+
pin_validation_enabled: true,
|
34
|
+
allow_backup_pins: true,
|
35
|
+
strict_mode: false
|
36
|
+
}.merge(config)
|
37
|
+
|
38
|
+
@pinned_hosts = {}
|
39
|
+
@validation_cache = {}
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add certificate pin for a host
|
43
|
+
# @param host [String] Hostname to pin
|
44
|
+
# @param pins [Array<String>] Array of certificate pins
|
45
|
+
# @param options [Hash] Additional options
|
46
|
+
def add_pin(host, pins, options = {})
|
47
|
+
normalized_host = normalize_host(host)
|
48
|
+
|
49
|
+
@pinned_hosts[normalized_host] = {
|
50
|
+
pins: Array(pins),
|
51
|
+
algorithm: options[:algorithm] || @config[:algorithm],
|
52
|
+
backup_pins: options[:backup_pins] || [],
|
53
|
+
strict_mode: options[:strict_mode] || @config[:strict_mode],
|
54
|
+
added_at: Time.now
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
# Validate certificate chain against pinned certificates
|
59
|
+
# @param host [String] Hostname being validated
|
60
|
+
# @param cert_chain [Array<OpenSSL::X509::Certificate>] Certificate chain
|
61
|
+
# @return [Hash] Validation result
|
62
|
+
def validate_pin(host, cert_chain)
|
63
|
+
normalized_host = normalize_host(host)
|
64
|
+
pin_config = @pinned_hosts[normalized_host]
|
65
|
+
|
66
|
+
return { valid: true, reason: "no_pin_configured" } unless pin_config
|
67
|
+
|
68
|
+
# Check cache first
|
69
|
+
cache_key = generate_cache_key(host, cert_chain)
|
70
|
+
return @validation_cache[cache_key] if @validation_cache[cache_key]
|
71
|
+
|
72
|
+
result = perform_pin_validation(pin_config, cert_chain)
|
73
|
+
|
74
|
+
# Cache result for performance
|
75
|
+
@validation_cache[cache_key] = result
|
76
|
+
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
# Extract certificate pin from certificate
|
81
|
+
# @param certificate [OpenSSL::X509::Certificate] Certificate to extract pin from
|
82
|
+
# @param algorithm [String] Hash algorithm to use
|
83
|
+
# @return [String] Certificate pin
|
84
|
+
def extract_pin(certificate, algorithm = "sha256")
|
85
|
+
public_key_der = certificate.public_key.to_der
|
86
|
+
|
87
|
+
case algorithm.downcase
|
88
|
+
when "sha256"
|
89
|
+
digest = OpenSSL::Digest::SHA256.digest(public_key_der)
|
90
|
+
"sha256/#{[digest].pack('m0')}"
|
91
|
+
when "sha1"
|
92
|
+
digest = OpenSSL::Digest::SHA1.digest(public_key_der)
|
93
|
+
"sha1/#{[digest].pack('m0')}"
|
94
|
+
else
|
95
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Get certificate chain from URL
|
100
|
+
# @param url [String] URL to get certificate chain from
|
101
|
+
# @return [Array<OpenSSL::X509::Certificate>] Certificate chain
|
102
|
+
def get_certificate_chain(url)
|
103
|
+
uri = URI.parse(url)
|
104
|
+
return [] unless uri.scheme == "https"
|
105
|
+
|
106
|
+
cert_chain = []
|
107
|
+
|
108
|
+
begin
|
109
|
+
tcp_socket = TCPSocket.new(uri.host, uri.port || 443)
|
110
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
111
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
112
|
+
|
113
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
114
|
+
ssl_socket.hostname = uri.host
|
115
|
+
ssl_socket.connect
|
116
|
+
|
117
|
+
cert_chain = ssl_socket.peer_cert_chain || []
|
118
|
+
|
119
|
+
ssl_socket.close
|
120
|
+
tcp_socket.close
|
121
|
+
rescue => e
|
122
|
+
# Log error but don't raise to allow graceful handling
|
123
|
+
warn "Certificate chain retrieval failed for #{url}: #{e.message}"
|
124
|
+
end
|
125
|
+
|
126
|
+
cert_chain
|
127
|
+
end
|
128
|
+
|
129
|
+
# Generate pins for a URL
|
130
|
+
# @param url [String] URL to generate pins for
|
131
|
+
# @param algorithm [String] Hash algorithm to use
|
132
|
+
# @return [Array<String>] Generated pins
|
133
|
+
def generate_pins_for_url(url, algorithm = "sha256")
|
134
|
+
cert_chain = get_certificate_chain(url)
|
135
|
+
return [] if cert_chain.empty?
|
136
|
+
|
137
|
+
cert_chain.map { |cert| extract_pin(cert, algorithm) }
|
138
|
+
end
|
139
|
+
|
140
|
+
# Validate current pinning configuration
|
141
|
+
# @return [Hash] Validation report
|
142
|
+
def validate_configuration
|
143
|
+
report = {
|
144
|
+
total_pins: @pinned_hosts.size,
|
145
|
+
valid_pins: 0,
|
146
|
+
invalid_pins: 0,
|
147
|
+
issues: []
|
148
|
+
}
|
149
|
+
|
150
|
+
@pinned_hosts.each do |host, config|
|
151
|
+
begin
|
152
|
+
# Test connectivity and pin validation
|
153
|
+
test_url = "https://#{host}"
|
154
|
+
cert_chain = get_certificate_chain(test_url)
|
155
|
+
|
156
|
+
if cert_chain.empty?
|
157
|
+
report[:issues] << "Cannot retrieve certificate chain for #{host}"
|
158
|
+
report[:invalid_pins] += 1
|
159
|
+
else
|
160
|
+
validation_result = perform_pin_validation(config, cert_chain)
|
161
|
+
if validation_result[:valid]
|
162
|
+
report[:valid_pins] += 1
|
163
|
+
else
|
164
|
+
report[:invalid_pins] += 1
|
165
|
+
report[:issues] << "Pin validation failed for #{host}: #{validation_result[:reason]}"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
rescue => e
|
169
|
+
report[:invalid_pins] += 1
|
170
|
+
report[:issues] << "Error validating #{host}: #{e.message}"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
report
|
175
|
+
end
|
176
|
+
|
177
|
+
# Get pinning status for all configured hosts
|
178
|
+
# @return [Hash] Pinning status
|
179
|
+
def pinning_status
|
180
|
+
{
|
181
|
+
enabled: @config[:pin_validation_enabled],
|
182
|
+
total_hosts: @pinned_hosts.size,
|
183
|
+
hosts: @pinned_hosts.keys,
|
184
|
+
cache_size: @validation_cache.size,
|
185
|
+
configuration: @config
|
186
|
+
}
|
187
|
+
end
|
188
|
+
|
189
|
+
# Clear validation cache
|
190
|
+
def clear_cache
|
191
|
+
@validation_cache.clear
|
192
|
+
end
|
193
|
+
|
194
|
+
# Remove pin for host
|
195
|
+
# @param host [String] Host to remove pin for
|
196
|
+
def remove_pin(host)
|
197
|
+
normalized_host = normalize_host(host)
|
198
|
+
@pinned_hosts.delete(normalized_host)
|
199
|
+
|
200
|
+
# Clear related cache entries
|
201
|
+
@validation_cache.delete_if { |key, _| key.include?(normalized_host) }
|
202
|
+
end
|
203
|
+
|
204
|
+
# Load pins from common CA configurations
|
205
|
+
# @param ca_name [String] CA name (letsencrypt, digicert, google)
|
206
|
+
# @param hosts [Array<String>] Hosts to apply CA pins to
|
207
|
+
def load_ca_pins(ca_name, hosts)
|
208
|
+
ca_pins = COMMON_CA_PINS[ca_name.downcase]
|
209
|
+
raise ArgumentError, "Unknown CA: #{ca_name}" unless ca_pins
|
210
|
+
|
211
|
+
Array(hosts).each do |host|
|
212
|
+
add_pin(host, ca_pins, backup_pins: ca_pins)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
|
218
|
+
def normalize_host(host)
|
219
|
+
# Remove protocol and path, keep only hostname
|
220
|
+
host.gsub(/^https?:\/\//, "").split("/").first.downcase
|
221
|
+
end
|
222
|
+
|
223
|
+
def generate_cache_key(host, cert_chain)
|
224
|
+
cert_fingerprints = cert_chain.map { |cert| cert.to_der }.join
|
225
|
+
"#{normalize_host(host)}_#{Digest::SHA256.hexdigest(cert_fingerprints)}"
|
226
|
+
end
|
227
|
+
|
228
|
+
def perform_pin_validation(pin_config, cert_chain)
|
229
|
+
return { valid: false, reason: "empty_certificate_chain" } if cert_chain.empty?
|
230
|
+
|
231
|
+
algorithm = pin_config[:algorithm]
|
232
|
+
expected_pins = pin_config[:pins] + (pin_config[:backup_pins] || [])
|
233
|
+
|
234
|
+
# Extract pins from certificate chain
|
235
|
+
actual_pins = cert_chain.map { |cert| extract_pin(cert, algorithm) }
|
236
|
+
|
237
|
+
# Check if any actual pin matches expected pins
|
238
|
+
matching_pins = actual_pins & expected_pins
|
239
|
+
|
240
|
+
if matching_pins.any?
|
241
|
+
{
|
242
|
+
valid: true,
|
243
|
+
reason: "pin_match_found",
|
244
|
+
matched_pins: matching_pins,
|
245
|
+
algorithm: algorithm
|
246
|
+
}
|
247
|
+
else
|
248
|
+
{
|
249
|
+
valid: false,
|
250
|
+
reason: "no_pin_match",
|
251
|
+
expected_pins: expected_pins,
|
252
|
+
actual_pins: actual_pins,
|
253
|
+
algorithm: algorithm
|
254
|
+
}
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|