hedra 1.0.1 → 2.0.1
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/LICENSE +1 -1
- data/README.md +399 -107
- data/config/example_config.yml +88 -10
- data/lib/hedra/analyzer.rb +74 -8
- data/lib/hedra/baseline.rb +83 -0
- data/lib/hedra/cache.rb +93 -0
- data/lib/hedra/certificate_checker.rb +94 -0
- data/lib/hedra/circuit_breaker.rb +80 -0
- data/lib/hedra/cli.rb +271 -18
- data/lib/hedra/config.rb +1 -1
- data/lib/hedra/exporter.rb +7 -0
- data/lib/hedra/html_reporter.rb +143 -0
- data/lib/hedra/http_client.rb +49 -9
- data/lib/hedra/progress_tracker.rb +45 -0
- data/lib/hedra/rate_limiter.rb +60 -0
- data/lib/hedra/security_txt_checker.rb +93 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +17 -9
- metadata +12 -4
data/config/example_config.yml
CHANGED
|
@@ -1,17 +1,95 @@
|
|
|
1
|
-
# Hedra Configuration
|
|
2
|
-
#
|
|
1
|
+
# Hedra Configuration File
|
|
2
|
+
# Place this file at ~/.hedra/config.yml
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# ============================================
|
|
5
|
+
# HTTP Client Settings
|
|
6
|
+
# ============================================
|
|
7
|
+
|
|
8
|
+
# Request timeout in seconds
|
|
5
9
|
timeout: 10
|
|
10
|
+
|
|
11
|
+
# Number of concurrent requests for batch operations
|
|
6
12
|
concurrency: 10
|
|
7
|
-
follow_redirects: false
|
|
8
|
-
user_agent: "Hedra/1.0.0"
|
|
9
13
|
|
|
10
|
-
#
|
|
14
|
+
# Custom User-Agent header
|
|
15
|
+
user_agent: "Hedra/2.0.0 Security Scanner"
|
|
16
|
+
|
|
17
|
+
# Follow HTTP redirects automatically
|
|
18
|
+
follow_redirects: true
|
|
19
|
+
|
|
20
|
+
# Maximum number of retry attempts for failed requests
|
|
21
|
+
max_retries: 3
|
|
22
|
+
|
|
23
|
+
# ============================================
|
|
24
|
+
# Proxy Settings (optional)
|
|
25
|
+
# ============================================
|
|
26
|
+
|
|
27
|
+
# HTTP/HTTPS/SOCKS proxy URL
|
|
28
|
+
# Uncomment to use a proxy
|
|
11
29
|
# proxy: "http://127.0.0.1:8080"
|
|
12
30
|
|
|
13
|
-
#
|
|
14
|
-
|
|
31
|
+
# ============================================
|
|
32
|
+
# Cache Settings
|
|
33
|
+
# ============================================
|
|
34
|
+
|
|
35
|
+
# Enable response caching to speed up repeated scans
|
|
36
|
+
cache_enabled: false
|
|
37
|
+
|
|
38
|
+
# Cache time-to-live in seconds (1 hour = 3600)
|
|
39
|
+
cache_ttl: 3600
|
|
40
|
+
|
|
41
|
+
# ============================================
|
|
42
|
+
# Security Check Settings
|
|
43
|
+
# ============================================
|
|
44
|
+
|
|
45
|
+
# Check SSL/TLS certificate validity and strength
|
|
46
|
+
check_certificates: true
|
|
47
|
+
|
|
48
|
+
# Check for security.txt file (RFC 9116)
|
|
49
|
+
check_security_txt: false
|
|
50
|
+
|
|
51
|
+
# ============================================
|
|
52
|
+
# Output Settings
|
|
53
|
+
# ============================================
|
|
54
|
+
|
|
55
|
+
# Default output format: table, json, csv, html
|
|
56
|
+
output_format: "table"
|
|
57
|
+
|
|
58
|
+
# Show progress bars for batch operations
|
|
59
|
+
progress_bar: true
|
|
60
|
+
|
|
61
|
+
# ============================================
|
|
62
|
+
# Rate Limiting (optional)
|
|
63
|
+
# ============================================
|
|
64
|
+
|
|
65
|
+
# Limit request rate to prevent server overload
|
|
66
|
+
# Format: number/period where period is s (second), m (minute), or h (hour)
|
|
67
|
+
# Examples: "10/s", "100/m", "1000/h"
|
|
68
|
+
# Uncomment to enable
|
|
69
|
+
# rate_limit: "10/s"
|
|
70
|
+
|
|
71
|
+
# ============================================
|
|
72
|
+
# Circuit Breaker Settings
|
|
73
|
+
# ============================================
|
|
74
|
+
|
|
75
|
+
# Number of failures before circuit opens
|
|
76
|
+
circuit_breaker_threshold: 5
|
|
77
|
+
|
|
78
|
+
# Timeout in seconds before attempting to close circuit
|
|
79
|
+
circuit_breaker_timeout: 60
|
|
80
|
+
|
|
81
|
+
# ============================================
|
|
82
|
+
# Scoring Weights (optional)
|
|
83
|
+
# ============================================
|
|
15
84
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
85
|
+
# Customize header importance weights (default shown)
|
|
86
|
+
# scoring:
|
|
87
|
+
# content-security-policy: 25
|
|
88
|
+
# strict-transport-security: 25
|
|
89
|
+
# x-frame-options: 15
|
|
90
|
+
# x-content-type-options: 10
|
|
91
|
+
# referrer-policy: 10
|
|
92
|
+
# permissions-policy: 5
|
|
93
|
+
# cross-origin-opener-policy: 5
|
|
94
|
+
# cross-origin-embedder-policy: 3
|
|
95
|
+
# cross-origin-resource-policy: 2
|
data/lib/hedra/analyzer.rb
CHANGED
|
@@ -61,13 +61,15 @@ module Hedra
|
|
|
61
61
|
}
|
|
62
62
|
}.freeze
|
|
63
63
|
|
|
64
|
-
def initialize
|
|
64
|
+
def initialize(check_certificates: true, check_security_txt: false)
|
|
65
65
|
@plugin_manager = PluginManager.new
|
|
66
66
|
@scorer = Scorer.new
|
|
67
|
+
@certificate_checker = check_certificates ? CertificateChecker.new : nil
|
|
68
|
+
@security_txt_checker = check_security_txt ? SecurityTxtChecker.new : nil
|
|
67
69
|
load_custom_rules
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
def analyze(url, headers)
|
|
72
|
+
def analyze(url, headers, http_client: nil)
|
|
71
73
|
normalized_headers = normalize_headers(headers)
|
|
72
74
|
findings = []
|
|
73
75
|
|
|
@@ -94,6 +96,12 @@ module Hedra
|
|
|
94
96
|
# Run plugin checks
|
|
95
97
|
findings.concat(@plugin_manager.run_checks(normalized_headers))
|
|
96
98
|
|
|
99
|
+
# Check SSL certificate
|
|
100
|
+
findings.concat(@certificate_checker.check(url)) if @certificate_checker
|
|
101
|
+
|
|
102
|
+
# Check security.txt
|
|
103
|
+
findings.concat(@security_txt_checker.check(url, http_client)) if @security_txt_checker && http_client
|
|
104
|
+
|
|
97
105
|
# Calculate security score
|
|
98
106
|
score = @scorer.calculate(normalized_headers, findings)
|
|
99
107
|
|
|
@@ -109,7 +117,14 @@ module Hedra
|
|
|
109
117
|
private
|
|
110
118
|
|
|
111
119
|
def normalize_headers(headers)
|
|
112
|
-
|
|
120
|
+
normalized = {}
|
|
121
|
+
headers.each do |key, value|
|
|
122
|
+
normalized_key = key.to_s.downcase
|
|
123
|
+
# Handle array values (multiple header values)
|
|
124
|
+
normalized_value = value.is_a?(Array) ? value.join(', ') : value.to_s
|
|
125
|
+
normalized[normalized_key] = normalized_value
|
|
126
|
+
end
|
|
127
|
+
normalized
|
|
113
128
|
end
|
|
114
129
|
|
|
115
130
|
def validate_header_values(headers)
|
|
@@ -117,7 +132,7 @@ module Hedra
|
|
|
117
132
|
|
|
118
133
|
# Validate CSP
|
|
119
134
|
if headers['content-security-policy']
|
|
120
|
-
csp = headers['content-security-policy']
|
|
135
|
+
csp = headers['content-security-policy'].to_s.downcase
|
|
121
136
|
if csp.include?('unsafe-inline') || csp.include?('unsafe-eval')
|
|
122
137
|
findings << {
|
|
123
138
|
header: 'content-security-policy',
|
|
@@ -126,11 +141,21 @@ module Hedra
|
|
|
126
141
|
recommended_fix: 'Remove unsafe-inline and unsafe-eval, use nonces or hashes'
|
|
127
142
|
}
|
|
128
143
|
end
|
|
144
|
+
|
|
145
|
+
# Check for wildcard sources
|
|
146
|
+
if csp =~ /\*(?!\.)/ && !csp.include?("'unsafe-inline'")
|
|
147
|
+
findings << {
|
|
148
|
+
header: 'content-security-policy',
|
|
149
|
+
issue: 'CSP uses wildcard (*) source',
|
|
150
|
+
severity: :info,
|
|
151
|
+
recommended_fix: 'Restrict sources to specific domains'
|
|
152
|
+
}
|
|
153
|
+
end
|
|
129
154
|
end
|
|
130
155
|
|
|
131
156
|
# Validate HSTS
|
|
132
157
|
if headers['strict-transport-security']
|
|
133
|
-
hsts = headers['strict-transport-security']
|
|
158
|
+
hsts = headers['strict-transport-security'].to_s
|
|
134
159
|
if hsts =~ /max-age=(\d+)/
|
|
135
160
|
max_age = ::Regexp.last_match(1).to_i
|
|
136
161
|
if max_age < 31_536_000
|
|
@@ -141,12 +166,29 @@ module Hedra
|
|
|
141
166
|
recommended_fix: 'Set max-age to at least 31536000'
|
|
142
167
|
}
|
|
143
168
|
end
|
|
169
|
+
else
|
|
170
|
+
findings << {
|
|
171
|
+
header: 'strict-transport-security',
|
|
172
|
+
issue: 'HSTS header missing max-age directive',
|
|
173
|
+
severity: :critical,
|
|
174
|
+
recommended_fix: 'Add max-age directive with value >= 31536000'
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check for includeSubDomains
|
|
179
|
+
unless hsts.downcase.include?('includesubdomains')
|
|
180
|
+
findings << {
|
|
181
|
+
header: 'strict-transport-security',
|
|
182
|
+
issue: 'HSTS missing includeSubDomains directive',
|
|
183
|
+
severity: :info,
|
|
184
|
+
recommended_fix: 'Add includeSubDomains to protect all subdomains'
|
|
185
|
+
}
|
|
144
186
|
end
|
|
145
187
|
end
|
|
146
188
|
|
|
147
189
|
# Validate X-Frame-Options
|
|
148
190
|
if headers['x-frame-options']
|
|
149
|
-
xfo = headers['x-frame-options'].upcase
|
|
191
|
+
xfo = headers['x-frame-options'].to_s.upcase
|
|
150
192
|
unless %w[DENY SAMEORIGIN].include?(xfo.split.first)
|
|
151
193
|
findings << {
|
|
152
194
|
header: 'x-frame-options',
|
|
@@ -159,7 +201,7 @@ module Hedra
|
|
|
159
201
|
|
|
160
202
|
# Validate X-Content-Type-Options
|
|
161
203
|
if headers['x-content-type-options']
|
|
162
|
-
xcto = headers['x-content-type-options'].downcase
|
|
204
|
+
xcto = headers['x-content-type-options'].to_s.downcase.strip
|
|
163
205
|
unless xcto == 'nosniff'
|
|
164
206
|
findings << {
|
|
165
207
|
header: 'x-content-type-options',
|
|
@@ -169,6 +211,25 @@ module Hedra
|
|
|
169
211
|
}
|
|
170
212
|
end
|
|
171
213
|
end
|
|
214
|
+
|
|
215
|
+
# Check for information disclosure headers
|
|
216
|
+
if headers['server']
|
|
217
|
+
findings << {
|
|
218
|
+
header: 'server',
|
|
219
|
+
issue: 'Server header exposes server information',
|
|
220
|
+
severity: :info,
|
|
221
|
+
recommended_fix: 'Remove or obfuscate Server header'
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if headers['x-powered-by']
|
|
226
|
+
findings << {
|
|
227
|
+
header: 'x-powered-by',
|
|
228
|
+
issue: 'X-Powered-By header exposes technology stack',
|
|
229
|
+
severity: :info,
|
|
230
|
+
recommended_fix: 'Remove X-Powered-By header'
|
|
231
|
+
}
|
|
232
|
+
end
|
|
172
233
|
|
|
173
234
|
findings
|
|
174
235
|
end
|
|
@@ -178,8 +239,13 @@ module Hedra
|
|
|
178
239
|
config_path = File.expand_path('~/.hedra/rules.yml')
|
|
179
240
|
return unless File.exist?(config_path)
|
|
180
241
|
|
|
181
|
-
|
|
242
|
+
content = File.read(config_path)
|
|
243
|
+
return if content.strip.empty?
|
|
244
|
+
|
|
245
|
+
rules = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
182
246
|
@custom_rules = rules['rules'] || []
|
|
247
|
+
rescue Psych::SyntaxError => e
|
|
248
|
+
warn "Invalid YAML in rules file: #{e.message}"
|
|
183
249
|
rescue StandardError => e
|
|
184
250
|
warn "Failed to load custom rules: #{e.message}"
|
|
185
251
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
# Manage security baselines for comparison
|
|
8
|
+
class Baseline
|
|
9
|
+
BASELINE_DIR = File.join(Config::CONFIG_DIR, 'baselines')
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
FileUtils.mkdir_p(BASELINE_DIR)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save(name, results)
|
|
16
|
+
baseline_file = File.join(BASELINE_DIR, "#{sanitize_name(name)}.json")
|
|
17
|
+
data = {
|
|
18
|
+
name: name,
|
|
19
|
+
created_at: Time.now.iso8601,
|
|
20
|
+
results: results
|
|
21
|
+
}
|
|
22
|
+
File.write(baseline_file, JSON.pretty_generate(data))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load(name)
|
|
26
|
+
baseline_file = File.join(BASELINE_DIR, "#{sanitize_name(name)}.json")
|
|
27
|
+
raise Error, "Baseline not found: #{name}" unless File.exist?(baseline_file)
|
|
28
|
+
|
|
29
|
+
JSON.parse(File.read(baseline_file), symbolize_names: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list
|
|
33
|
+
Dir.glob(File.join(BASELINE_DIR, '*.json')).map do |file|
|
|
34
|
+
data = JSON.parse(File.read(file), symbolize_names: true)
|
|
35
|
+
{
|
|
36
|
+
name: data[:name],
|
|
37
|
+
created_at: data[:created_at],
|
|
38
|
+
url_count: data[:results].length
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
rescue StandardError
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(name)
|
|
46
|
+
baseline_file = File.join(BASELINE_DIR, "#{sanitize_name(name)}.json")
|
|
47
|
+
raise Error, "Baseline not found: #{name}" unless File.exist?(baseline_file)
|
|
48
|
+
|
|
49
|
+
File.delete(baseline_file)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def compare(baseline_name, current_results)
|
|
53
|
+
baseline = load(baseline_name)
|
|
54
|
+
baseline_results = baseline[:results]
|
|
55
|
+
|
|
56
|
+
comparisons = []
|
|
57
|
+
|
|
58
|
+
current_results.each do |current|
|
|
59
|
+
baseline_result = baseline_results.find { |b| b[:url] == current[:url] }
|
|
60
|
+
next unless baseline_result
|
|
61
|
+
|
|
62
|
+
comparison = {
|
|
63
|
+
url: current[:url],
|
|
64
|
+
baseline_score: baseline_result[:score],
|
|
65
|
+
current_score: current[:score],
|
|
66
|
+
score_change: current[:score] - baseline_result[:score],
|
|
67
|
+
new_findings: current[:findings] - baseline_result[:findings],
|
|
68
|
+
resolved_findings: baseline_result[:findings] - current[:findings]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
comparisons << comparison
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
comparisons
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def sanitize_name(name)
|
|
80
|
+
name.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/hedra/cache.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module Hedra
|
|
8
|
+
# Simple file-based cache for HTTP responses
|
|
9
|
+
class Cache
|
|
10
|
+
DEFAULT_TTL = 3600 # 1 hour
|
|
11
|
+
MAX_CACHE_SIZE = 1000 # Maximum number of cache files
|
|
12
|
+
|
|
13
|
+
def initialize(cache_dir: nil, ttl: DEFAULT_TTL, verbose: false)
|
|
14
|
+
@cache_dir = cache_dir || File.join(Config::CONFIG_DIR, 'cache')
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@verbose = verbose
|
|
17
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
18
|
+
cleanup_if_needed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(key)
|
|
22
|
+
cache_file = cache_path(key)
|
|
23
|
+
return nil unless File.exist?(cache_file)
|
|
24
|
+
|
|
25
|
+
data = JSON.parse(File.read(cache_file))
|
|
26
|
+
|
|
27
|
+
if expired?(data['timestamp'])
|
|
28
|
+
File.delete(cache_file) # Clean up expired file immediately
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
data['value']
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
# Corrupted cache file, delete it
|
|
35
|
+
File.delete(cache_file) if File.exist?(cache_file)
|
|
36
|
+
nil
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
warn "Cache read error: #{e.message}" if @verbose
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set(key, value)
|
|
43
|
+
cache_file = cache_path(key)
|
|
44
|
+
data = {
|
|
45
|
+
'timestamp' => Time.now.to_i,
|
|
46
|
+
'value' => value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Atomic write to prevent corruption
|
|
50
|
+
temp_file = "#{cache_file}.tmp"
|
|
51
|
+
File.write(temp_file, JSON.generate(data))
|
|
52
|
+
File.rename(temp_file, cache_file)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
warn "Cache write error: #{e.message}" if @verbose
|
|
55
|
+
File.delete(temp_file) if File.exist?(temp_file)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
FileUtils.rm_rf(@cache_dir)
|
|
60
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear_expired
|
|
64
|
+
Dir.glob(File.join(@cache_dir, '*')).each do |file|
|
|
65
|
+
data = JSON.parse(File.read(file))
|
|
66
|
+
File.delete(file) if expired?(data['timestamp'])
|
|
67
|
+
rescue StandardError
|
|
68
|
+
# Skip invalid cache files
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def cache_path(key)
|
|
75
|
+
hash = Digest::SHA256.hexdigest(key)
|
|
76
|
+
File.join(@cache_dir, hash)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def expired?(timestamp)
|
|
80
|
+
(Time.now.to_i - timestamp) > @ttl
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cleanup_if_needed
|
|
84
|
+
cache_files = Dir.glob(File.join(@cache_dir, '*'))
|
|
85
|
+
return if cache_files.length < MAX_CACHE_SIZE
|
|
86
|
+
|
|
87
|
+
# Remove oldest files if cache is too large
|
|
88
|
+
cache_files.sort_by { |f| File.mtime(f) }
|
|
89
|
+
.first(cache_files.length - MAX_CACHE_SIZE + 100)
|
|
90
|
+
.each { |f| File.delete(f) rescue nil }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Hedra
|
|
8
|
+
# Check SSL/TLS certificate validity and security
|
|
9
|
+
class CertificateChecker
|
|
10
|
+
EXPIRY_WARNING_DAYS = 30
|
|
11
|
+
|
|
12
|
+
def check(url)
|
|
13
|
+
uri = URI.parse(url)
|
|
14
|
+
return nil unless uri.scheme == 'https'
|
|
15
|
+
|
|
16
|
+
findings = []
|
|
17
|
+
cert_info = fetch_certificate(uri.host, uri.port || 443)
|
|
18
|
+
|
|
19
|
+
return findings unless cert_info
|
|
20
|
+
|
|
21
|
+
# Check expiry
|
|
22
|
+
days_until_expiry = ((cert_info[:not_after] - Time.now) / 86_400).to_i
|
|
23
|
+
if days_until_expiry.negative?
|
|
24
|
+
findings << {
|
|
25
|
+
header: 'ssl-certificate',
|
|
26
|
+
issue: 'SSL certificate has expired',
|
|
27
|
+
severity: :critical,
|
|
28
|
+
recommended_fix: 'Renew SSL certificate immediately'
|
|
29
|
+
}
|
|
30
|
+
elsif days_until_expiry < EXPIRY_WARNING_DAYS
|
|
31
|
+
findings << {
|
|
32
|
+
header: 'ssl-certificate',
|
|
33
|
+
issue: "SSL certificate expires in #{days_until_expiry} days",
|
|
34
|
+
severity: :warning,
|
|
35
|
+
recommended_fix: 'Renew SSL certificate soon'
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check signature algorithm
|
|
40
|
+
if weak_signature_algorithm?(cert_info[:signature_algorithm])
|
|
41
|
+
findings << {
|
|
42
|
+
header: 'ssl-certificate',
|
|
43
|
+
issue: "Weak signature algorithm: #{cert_info[:signature_algorithm]}",
|
|
44
|
+
severity: :warning,
|
|
45
|
+
recommended_fix: 'Use SHA256 or stronger'
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check key size
|
|
50
|
+
if cert_info[:key_size] && cert_info[:key_size] < 2048
|
|
51
|
+
findings << {
|
|
52
|
+
header: 'ssl-certificate',
|
|
53
|
+
issue: "Weak key size: #{cert_info[:key_size]} bits",
|
|
54
|
+
severity: :critical,
|
|
55
|
+
recommended_fix: 'Use at least 2048-bit RSA or 256-bit ECC'
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
findings
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
warn "Certificate check failed: #{e.message}"
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def fetch_certificate(host, port)
|
|
68
|
+
tcp_socket = TCPSocket.new(host, port)
|
|
69
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
70
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
71
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
|
|
72
|
+
ssl_socket.connect
|
|
73
|
+
|
|
74
|
+
cert = ssl_socket.peer_cert
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
subject: cert.subject.to_s,
|
|
78
|
+
issuer: cert.issuer.to_s,
|
|
79
|
+
not_before: cert.not_before,
|
|
80
|
+
not_after: cert.not_after,
|
|
81
|
+
signature_algorithm: cert.signature_algorithm,
|
|
82
|
+
key_size: cert.public_key.respond_to?(:n) ? cert.public_key.n.num_bits : nil
|
|
83
|
+
}
|
|
84
|
+
ensure
|
|
85
|
+
ssl_socket&.close
|
|
86
|
+
tcp_socket&.close
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def weak_signature_algorithm?(algorithm)
|
|
90
|
+
weak_algorithms = %w[md5 sha1]
|
|
91
|
+
weak_algorithms.any? { |weak| algorithm.downcase.include?(weak) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hedra
|
|
4
|
+
# Circuit breaker pattern to prevent cascading failures
|
|
5
|
+
class CircuitBreaker
|
|
6
|
+
FAILURE_THRESHOLD = 5
|
|
7
|
+
TIMEOUT_SECONDS = 60
|
|
8
|
+
HALF_OPEN_ATTEMPTS = 3
|
|
9
|
+
|
|
10
|
+
attr_reader :state, :failure_count, :last_failure_time
|
|
11
|
+
|
|
12
|
+
def initialize(failure_threshold: FAILURE_THRESHOLD, timeout: TIMEOUT_SECONDS)
|
|
13
|
+
@failure_threshold = failure_threshold
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
@failure_count = 0
|
|
16
|
+
@last_failure_time = nil
|
|
17
|
+
@state = :closed
|
|
18
|
+
@half_open_attempts = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
raise CircuitOpenError, 'Circuit breaker is open' if open? && !should_attempt_reset?
|
|
23
|
+
|
|
24
|
+
if open? && should_attempt_reset?
|
|
25
|
+
@state = :half_open
|
|
26
|
+
@half_open_attempts = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
result = yield
|
|
31
|
+
on_success
|
|
32
|
+
result
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
on_failure
|
|
35
|
+
raise e
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def open?
|
|
40
|
+
@state == :open
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def closed?
|
|
44
|
+
@state == :closed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def half_open?
|
|
48
|
+
@state == :half_open
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def on_success
|
|
54
|
+
if half_open?
|
|
55
|
+
@half_open_attempts += 1
|
|
56
|
+
if @half_open_attempts >= HALF_OPEN_ATTEMPTS
|
|
57
|
+
@state = :closed
|
|
58
|
+
@failure_count = 0
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
@failure_count = 0
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_failure
|
|
66
|
+
@failure_count += 1
|
|
67
|
+
@last_failure_time = Time.now
|
|
68
|
+
|
|
69
|
+
return unless @failure_count >= @failure_threshold
|
|
70
|
+
|
|
71
|
+
@state = :open
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def should_attempt_reset?
|
|
75
|
+
@last_failure_time && (Time.now - @last_failure_time) >= @timeout
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class CircuitOpenError < Error; end
|
|
80
|
+
end
|