hedra 2.0.1 → 2.0.3
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 +3 -3
- data/lib/hedra/analyzer.rb +29 -13
- 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 +14 -4
- data/lib/hedra/config.rb +11 -2
- data/lib/hedra/plugin_manager.rb +23 -1
- data/lib/hedra/progress_tracker.rb +6 -1
- 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/url_validator.rb +94 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +2 -0
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04726510b1778538e154b1eea4d68bbbed1fe306d9e026e73275e6f6cbf586ed
|
|
4
|
+
data.tar.gz: 36f7716980ea5fb86bd431c2b6f48ea591b4b9e793557893623d0a4c1f88a6fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7396aca2eb8bc377f7a203c15729aa48864b3394a79b53b3490c4c2e06719696e313b3ebd097fce4f69b9006c5a44903b8ab94a7dd68da257289e584c811539
|
|
7
|
+
data.tar.gz: b50e9f48dda4b48c505ce948a01d3b5b2d5f2a669d459d6398f6c8801ce18bda53edf1da2132b045743e8575225c194fde6e42deb20eb530d5175aa1af6f54a1
|
data/README.md
CHANGED
|
@@ -493,9 +493,9 @@ hedra scan https://slow-server.com --timeout 60
|
|
|
493
493
|
|
|
494
494
|
## Resources
|
|
495
495
|
|
|
496
|
-
**GitHub:** https://github.com/
|
|
496
|
+
**GitHub:** https://github.com/bl4ckstack/hedra
|
|
497
497
|
**RubyGems:** https://rubygems.org/gems/hedra
|
|
498
|
-
**Issues:** https://github.com/
|
|
498
|
+
**Issues:** https://github.com/bl4ckstack/hedra/issues
|
|
499
499
|
**OWASP Headers:** https://owasp.org/www-project-secure-headers/
|
|
500
500
|
|
|
501
501
|
## License
|
|
@@ -504,4 +504,4 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
504
504
|
|
|
505
505
|
---
|
|
506
506
|
|
|
507
|
-
**Built by [BlackStack](https://github.com/bl4ckstack)** • Securing the web, one header at a time.
|
|
507
|
+
**Built by [BlackStack](https://github.com/bl4ckstack)** • Securing the web, one header at a time.
|
data/lib/hedra/analyzer.rb
CHANGED
|
@@ -66,6 +66,8 @@ module Hedra
|
|
|
66
66
|
@scorer = Scorer.new
|
|
67
67
|
@certificate_checker = check_certificates ? CertificateChecker.new : nil
|
|
68
68
|
@security_txt_checker = check_security_txt ? SecurityTxtChecker.new : nil
|
|
69
|
+
@custom_rules = []
|
|
70
|
+
@mutex = Mutex.new # Thread safety for custom rules
|
|
69
71
|
load_custom_rules
|
|
70
72
|
end
|
|
71
73
|
|
|
@@ -119,9 +121,15 @@ module Hedra
|
|
|
119
121
|
def normalize_headers(headers)
|
|
120
122
|
normalized = {}
|
|
121
123
|
headers.each do |key, value|
|
|
124
|
+
next if value.nil? # Skip nil values
|
|
125
|
+
|
|
122
126
|
normalized_key = key.to_s.downcase
|
|
123
127
|
# Handle array values (multiple header values)
|
|
124
|
-
normalized_value = value.is_a?(Array)
|
|
128
|
+
normalized_value = if value.is_a?(Array)
|
|
129
|
+
value.compact.join(', ')
|
|
130
|
+
else
|
|
131
|
+
value.to_s
|
|
132
|
+
end
|
|
125
133
|
normalized[normalized_key] = normalized_value
|
|
126
134
|
end
|
|
127
135
|
normalized
|
|
@@ -253,30 +261,38 @@ module Hedra
|
|
|
253
261
|
def apply_custom_rules(headers)
|
|
254
262
|
findings = []
|
|
255
263
|
|
|
256
|
-
@
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
@mutex.synchronize do
|
|
265
|
+
@custom_rules.each do |rule|
|
|
266
|
+
header_name = rule['header'].downcase
|
|
267
|
+
pattern = rule['pattern'] ? Regexp.new(rule['pattern']) : nil
|
|
259
268
|
|
|
260
|
-
|
|
261
|
-
findings << {
|
|
262
|
-
header: header_name,
|
|
263
|
-
issue: rule['message'],
|
|
264
|
-
severity: rule['severity'].to_sym,
|
|
265
|
-
recommended_fix: rule['fix']
|
|
266
|
-
}
|
|
267
|
-
elsif rule['type'] == 'pattern' && headers[header_name]
|
|
268
|
-
if pattern && headers[header_name] =~ pattern
|
|
269
|
+
if rule['type'] == 'missing' && !headers.key?(header_name)
|
|
269
270
|
findings << {
|
|
270
271
|
header: header_name,
|
|
271
272
|
issue: rule['message'],
|
|
272
273
|
severity: rule['severity'].to_sym,
|
|
273
274
|
recommended_fix: rule['fix']
|
|
274
275
|
}
|
|
276
|
+
elsif rule['type'] == 'pattern' && headers[header_name]
|
|
277
|
+
if pattern && headers[header_name] =~ pattern
|
|
278
|
+
findings << {
|
|
279
|
+
header: header_name,
|
|
280
|
+
issue: rule['message'],
|
|
281
|
+
severity: rule['severity'].to_sym,
|
|
282
|
+
recommended_fix: rule['fix']
|
|
283
|
+
}
|
|
284
|
+
end
|
|
275
285
|
end
|
|
276
286
|
end
|
|
277
287
|
end
|
|
278
288
|
|
|
279
289
|
findings
|
|
280
290
|
end
|
|
291
|
+
|
|
292
|
+
def reload_custom_rules
|
|
293
|
+
@mutex.synchronize do
|
|
294
|
+
load_custom_rules
|
|
295
|
+
end
|
|
296
|
+
end
|
|
281
297
|
end
|
|
282
298
|
end
|
data/lib/hedra/banner.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hedra
|
|
4
|
+
# Display banner and security quotes
|
|
5
|
+
class Banner
|
|
6
|
+
SECURITY_QUOTES = [
|
|
7
|
+
"Security is not a product, but a process. - Bruce Schneier",
|
|
8
|
+
"The only truly secure system is one that is powered off. - Gene Spafford",
|
|
9
|
+
"HTTPS everywhere is not optional, it's essential. - Let's Encrypt",
|
|
10
|
+
"A chain is only as strong as its weakest link. - Security Headers",
|
|
11
|
+
"Defense in depth: Layer your security like an onion. - OWASP",
|
|
12
|
+
"CSP is your first line of defense against XSS attacks.",
|
|
13
|
+
"HSTS ensures your users always connect securely.",
|
|
14
|
+
"Security headers are the low-hanging fruit of web security.",
|
|
15
|
+
"TLS 1.3: Faster, stronger, more secure.",
|
|
16
|
+
"X-Frame-Options: Because clickjacking is still a thing.",
|
|
17
|
+
"Trust, but verify. Then verify again. - Security Principle",
|
|
18
|
+
"Security is a journey, not a destination.",
|
|
19
|
+
"Good security is invisible until it's needed.",
|
|
20
|
+
"Headers don't lie, but they can tell you everything.",
|
|
21
|
+
"CORS: Sharing is caring, but be careful who you trust.",
|
|
22
|
+
"Every header matters. Every connection counts.",
|
|
23
|
+
"Secure by default, not by accident.",
|
|
24
|
+
"Your security posture is only as good as your weakest header.",
|
|
25
|
+
"SSL/TLS: The foundation of web security.",
|
|
26
|
+
"Content-Security-Policy: Your app's security bouncer."
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
ASCII_ART = <<~ART
|
|
30
|
+
_ _ _
|
|
31
|
+
| | | | ___ __| |_ __ __ _
|
|
32
|
+
| |_| |/ _ \\/ _` | '__/ _` |
|
|
33
|
+
| _ | __/ (_| | | | (_| |
|
|
34
|
+
|_| |_|\\___|\\__,_|_| \\__,_|
|
|
35
|
+
ART
|
|
36
|
+
|
|
37
|
+
def self.show
|
|
38
|
+
require 'pastel'
|
|
39
|
+
pastel = Pastel.new
|
|
40
|
+
|
|
41
|
+
puts "\n"
|
|
42
|
+
puts pastel.cyan.bold(ASCII_ART)
|
|
43
|
+
puts " #{pastel.bold('Security Header Analyzer')} #{pastel.dim("v#{VERSION}")}"
|
|
44
|
+
puts " #{pastel.italic.dim(random_quote)}"
|
|
45
|
+
puts "\n"
|
|
46
|
+
puts " Usage: #{pastel.yellow('hedra')} #{pastel.green('[command]')} #{pastel.dim('[options]')}"
|
|
47
|
+
puts " Run '#{pastel.yellow('hedra help')}' for more information"
|
|
48
|
+
puts "\n"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.random_quote
|
|
52
|
+
SECURITY_QUOTES.sample
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/hedra/cache.rb
CHANGED
|
@@ -14,22 +14,38 @@ module Hedra
|
|
|
14
14
|
@cache_dir = cache_dir || File.join(Config::CONFIG_DIR, 'cache')
|
|
15
15
|
@ttl = ttl
|
|
16
16
|
@verbose = verbose
|
|
17
|
+
@mutex = Mutex.new # Thread safety for cache operations
|
|
18
|
+
@memory_cache = {} # In-memory cache to reduce disk I/O
|
|
19
|
+
@memory_cache_size = 100
|
|
17
20
|
FileUtils.mkdir_p(@cache_dir)
|
|
18
21
|
cleanup_if_needed
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def get(key)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
# Check memory cache first
|
|
27
|
+
if @memory_cache.key?(key)
|
|
28
|
+
cached = @memory_cache[key]
|
|
29
|
+
return cached[:value] unless expired?(cached[:timestamp])
|
|
30
|
+
|
|
31
|
+
@memory_cache.delete(key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
cache_file = cache_path(key)
|
|
35
|
+
return nil unless File.exist?(cache_file)
|
|
36
|
+
|
|
37
|
+
data = JSON.parse(File.read(cache_file))
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
if expired?(data['timestamp'])
|
|
40
|
+
File.delete(cache_file) # Clean up expired file immediately
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Store in memory cache
|
|
45
|
+
store_in_memory(key, data['timestamp'], data['value'])
|
|
46
|
+
|
|
47
|
+
data['value']
|
|
48
|
+
end
|
|
33
49
|
rescue JSON::ParserError
|
|
34
50
|
# Corrupted cache file, delete it
|
|
35
51
|
File.delete(cache_file) if File.exist?(cache_file)
|
|
@@ -40,24 +56,33 @@ module Hedra
|
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
def set(key, value)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
timestamp = Time.now.to_i
|
|
61
|
+
cache_file = cache_path(key)
|
|
62
|
+
data = {
|
|
63
|
+
'timestamp' => timestamp,
|
|
64
|
+
'value' => value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Store in memory cache
|
|
68
|
+
store_in_memory(key, timestamp, value)
|
|
69
|
+
|
|
70
|
+
# Atomic write to prevent corruption
|
|
71
|
+
temp_file = "#{cache_file}.tmp"
|
|
72
|
+
File.write(temp_file, JSON.generate(data))
|
|
73
|
+
File.rename(temp_file, cache_file)
|
|
74
|
+
end
|
|
53
75
|
rescue StandardError => e
|
|
54
76
|
warn "Cache write error: #{e.message}" if @verbose
|
|
55
77
|
File.delete(temp_file) if File.exist?(temp_file)
|
|
56
78
|
end
|
|
57
79
|
|
|
58
80
|
def clear
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@memory_cache.clear
|
|
83
|
+
FileUtils.rm_rf(@cache_dir)
|
|
84
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
85
|
+
end
|
|
61
86
|
end
|
|
62
87
|
|
|
63
88
|
def clear_expired
|
|
@@ -89,5 +114,16 @@ module Hedra
|
|
|
89
114
|
.first(cache_files.length - MAX_CACHE_SIZE + 100)
|
|
90
115
|
.each { |f| File.delete(f) rescue nil }
|
|
91
116
|
end
|
|
117
|
+
|
|
118
|
+
def store_in_memory(key, timestamp, value)
|
|
119
|
+
# Limit memory cache size to prevent memory leaks
|
|
120
|
+
if @memory_cache.size >= @memory_cache_size
|
|
121
|
+
# Remove oldest entry
|
|
122
|
+
oldest_key = @memory_cache.keys.first
|
|
123
|
+
@memory_cache.delete(oldest_key)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@memory_cache[key] = { timestamp: timestamp, value: value }
|
|
127
|
+
end
|
|
92
128
|
end
|
|
93
129
|
end
|
|
@@ -16,14 +16,17 @@ module Hedra
|
|
|
16
16
|
@last_failure_time = nil
|
|
17
17
|
@state = :closed
|
|
18
18
|
@half_open_attempts = 0
|
|
19
|
+
@mutex = Mutex.new # Thread safety
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def call
|
|
22
|
-
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
raise CircuitOpenError, 'Circuit breaker is open' if open? && !should_attempt_reset?
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
if open? && should_attempt_reset?
|
|
27
|
+
@state = :half_open
|
|
28
|
+
@half_open_attempts = 0
|
|
29
|
+
end
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
begin
|
|
@@ -51,28 +54,34 @@ module Hedra
|
|
|
51
54
|
private
|
|
52
55
|
|
|
53
56
|
def on_success
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
if half_open?
|
|
59
|
+
@half_open_attempts += 1
|
|
60
|
+
if @half_open_attempts >= HALF_OPEN_ATTEMPTS
|
|
61
|
+
@state = :closed
|
|
62
|
+
@failure_count = 0
|
|
63
|
+
end
|
|
64
|
+
else
|
|
58
65
|
@failure_count = 0
|
|
59
66
|
end
|
|
60
|
-
else
|
|
61
|
-
@failure_count = 0
|
|
62
67
|
end
|
|
63
68
|
end
|
|
64
69
|
|
|
65
70
|
def on_failure
|
|
66
|
-
@
|
|
67
|
-
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
@failure_count += 1
|
|
73
|
+
@last_failure_time = Time.now
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
return unless @failure_count >= @failure_threshold
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
@state = :open
|
|
78
|
+
end
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
def should_attempt_reset?
|
|
75
|
-
@
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@last_failure_time && (Time.now - @last_failure_time) >= @timeout
|
|
84
|
+
end
|
|
76
85
|
end
|
|
77
86
|
end
|
|
78
87
|
|
data/lib/hedra/cli.rb
CHANGED
|
@@ -155,6 +155,14 @@ module Hedra
|
|
|
155
155
|
true
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Override to show banner when no command is given
|
|
159
|
+
def self.start(given_args = ARGV, config = {})
|
|
160
|
+
if given_args.empty? || (given_args.length == 1 && %w[-h --help help].include?(given_args.first))
|
|
161
|
+
Banner.show unless given_args.include?('help')
|
|
162
|
+
end
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
158
166
|
desc 'scan URL_OR_FILE', 'Scan one or multiple URLs for security headers'
|
|
159
167
|
option :file, type: :boolean, aliases: '-f', desc: 'Treat argument as file with URLs'
|
|
160
168
|
option :concurrency, type: :numeric, aliases: '-c', default: 10, desc: 'Concurrent requests'
|
|
@@ -328,6 +336,11 @@ module Hedra
|
|
|
328
336
|
say "Exported to #{options[:output]}", :green unless options[:quiet]
|
|
329
337
|
end
|
|
330
338
|
|
|
339
|
+
desc 'version', 'Show version information'
|
|
340
|
+
def version
|
|
341
|
+
Banner.show
|
|
342
|
+
end
|
|
343
|
+
|
|
331
344
|
desc 'plugin SUBCOMMAND', 'Manage plugins'
|
|
332
345
|
subcommand 'plugin', Hedra::PluginCLI
|
|
333
346
|
|
|
@@ -432,10 +445,7 @@ module Hedra
|
|
|
432
445
|
end
|
|
433
446
|
|
|
434
447
|
def valid_url?(url)
|
|
435
|
-
|
|
436
|
-
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
437
|
-
rescue URI::InvalidURIError
|
|
438
|
-
false
|
|
448
|
+
UrlValidator.valid?(url)
|
|
439
449
|
end
|
|
440
450
|
|
|
441
451
|
def with_concurrency(items, concurrency)
|
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
|
data/lib/hedra/plugin_manager.rb
CHANGED
|
@@ -16,9 +16,17 @@ module Hedra
|
|
|
16
16
|
|
|
17
17
|
def install(path)
|
|
18
18
|
raise Error, "Plugin file not found: #{path}" unless File.exist?(path)
|
|
19
|
+
raise Error, "Not a Ruby file: #{path}" unless path.end_with?('.rb')
|
|
20
|
+
|
|
21
|
+
# Validate plugin syntax before installing
|
|
22
|
+
validate_plugin_syntax(path)
|
|
19
23
|
|
|
20
24
|
plugin_name = File.basename(path)
|
|
21
25
|
dest = File.join(@plugin_dir, plugin_name)
|
|
26
|
+
|
|
27
|
+
# Backup existing plugin if it exists
|
|
28
|
+
backup_existing_plugin(dest) if File.exist?(dest)
|
|
29
|
+
|
|
22
30
|
FileUtils.cp(path, dest)
|
|
23
31
|
load_plugin(dest)
|
|
24
32
|
end
|
|
@@ -48,7 +56,7 @@ module Hedra
|
|
|
48
56
|
def load_plugins
|
|
49
57
|
@plugins = []
|
|
50
58
|
|
|
51
|
-
Dir.glob(File.join(@plugin_dir, '*.rb')).each do |file|
|
|
59
|
+
Dir.glob(File.join(@plugin_dir, '*.rb')).sort.each do |file|
|
|
52
60
|
load_plugin(file)
|
|
53
61
|
end
|
|
54
62
|
end
|
|
@@ -59,6 +67,20 @@ module Hedra
|
|
|
59
67
|
rescue StandardError => e
|
|
60
68
|
warn "Failed to load plugin #{file}: #{e.message}"
|
|
61
69
|
end
|
|
70
|
+
|
|
71
|
+
def validate_plugin_syntax(path)
|
|
72
|
+
code = File.read(path)
|
|
73
|
+
RubyVM::InstructionSequence.compile(code)
|
|
74
|
+
rescue SyntaxError => e
|
|
75
|
+
raise Error, "Plugin has syntax errors: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def backup_existing_plugin(dest)
|
|
79
|
+
backup_path = "#{dest}.backup"
|
|
80
|
+
FileUtils.cp(dest, backup_path)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
warn "Failed to backup existing plugin: #{e.message}"
|
|
83
|
+
end
|
|
62
84
|
end
|
|
63
85
|
|
|
64
86
|
# Base class for plugins
|
|
@@ -23,7 +23,12 @@ module Hedra
|
|
|
23
23
|
|
|
24
24
|
puts "\n"
|
|
25
25
|
elapsed = Time.now - @start_time
|
|
26
|
-
|
|
26
|
+
rate = @total / elapsed
|
|
27
|
+
puts "Completed #{@total} items in #{elapsed.round(2)}s (#{rate.round(2)} items/s)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def percentage
|
|
31
|
+
(@current.to_f / @total * 100).round(1)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
data/lib/hedra/rate_limiter.rb
CHANGED
|
@@ -51,10 +51,16 @@ module Hedra
|
|
|
51
51
|
def refill_tokens
|
|
52
52
|
now = Time.now
|
|
53
53
|
elapsed = now - @last_refill
|
|
54
|
+
return if elapsed <= 0
|
|
54
55
|
|
|
55
56
|
tokens_to_add = (elapsed / @period) * @requests
|
|
56
57
|
@tokens = [@tokens + tokens_to_add, @requests].min
|
|
57
58
|
@last_refill = now
|
|
58
59
|
end
|
|
60
|
+
|
|
61
|
+
def reset
|
|
62
|
+
@tokens = @requests
|
|
63
|
+
@last_refill = Time.now
|
|
64
|
+
end
|
|
59
65
|
end
|
|
60
66
|
end
|
data/lib/hedra/scorer.rb
CHANGED
|
@@ -23,9 +23,10 @@ module Hedra
|
|
|
23
23
|
def calculate(headers, findings)
|
|
24
24
|
base_score = calculate_base_score(headers)
|
|
25
25
|
penalty = calculate_penalty(findings)
|
|
26
|
+
bonus = calculate_bonus(headers)
|
|
26
27
|
|
|
27
|
-
score = [base_score - penalty, 0].max
|
|
28
|
-
score.round
|
|
28
|
+
score = [base_score - penalty + bonus, 0].max
|
|
29
|
+
[score.round, 100].min # Cap at 100
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
private
|
|
@@ -50,5 +51,26 @@ module Hedra
|
|
|
50
51
|
|
|
51
52
|
penalty
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
def calculate_bonus(headers)
|
|
56
|
+
bonus = 0
|
|
57
|
+
|
|
58
|
+
# Bonus for HSTS with includeSubDomains
|
|
59
|
+
if headers['strict-transport-security']&.include?('includeSubDomains')
|
|
60
|
+
bonus += 2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Bonus for HSTS with preload
|
|
64
|
+
if headers['strict-transport-security']&.include?('preload')
|
|
65
|
+
bonus += 3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Bonus for having all recommended headers
|
|
69
|
+
if HEADER_WEIGHTS.keys.all? { |h| headers.key?(h) }
|
|
70
|
+
bonus += 5
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
bonus
|
|
74
|
+
end
|
|
53
75
|
end
|
|
54
76
|
end
|
|
@@ -10,7 +10,12 @@ module Hedra
|
|
|
10
10
|
|
|
11
11
|
def check(url, http_client)
|
|
12
12
|
uri = URI.parse(url)
|
|
13
|
-
|
|
13
|
+
port_part = if uri.port && ![80, 443].include?(uri.port)
|
|
14
|
+
":#{uri.port}"
|
|
15
|
+
else
|
|
16
|
+
''
|
|
17
|
+
end
|
|
18
|
+
base_url = "#{uri.scheme}://#{uri.host}#{port_part}"
|
|
14
19
|
|
|
15
20
|
findings = []
|
|
16
21
|
found = false
|
|
@@ -19,7 +24,9 @@ module Hedra
|
|
|
19
24
|
response = http_client.get("#{base_url}#{path}")
|
|
20
25
|
if response.status.success?
|
|
21
26
|
found = true
|
|
22
|
-
|
|
27
|
+
content = response.body.to_s
|
|
28
|
+
findings.concat(validate_security_txt(content))
|
|
29
|
+
findings.concat(check_signed(content))
|
|
23
30
|
break
|
|
24
31
|
end
|
|
25
32
|
rescue StandardError
|
|
@@ -43,6 +50,22 @@ module Hedra
|
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
53
|
+
def check_signed(content)
|
|
54
|
+
findings = []
|
|
55
|
+
|
|
56
|
+
# Check if security.txt is signed (PGP signature)
|
|
57
|
+
unless content.include?('-----BEGIN PGP SIGNATURE-----')
|
|
58
|
+
findings << {
|
|
59
|
+
header: 'security.txt',
|
|
60
|
+
issue: 'security.txt is not digitally signed',
|
|
61
|
+
severity: :info,
|
|
62
|
+
recommended_fix: 'Consider signing security.txt with PGP for authenticity'
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
findings
|
|
67
|
+
end
|
|
68
|
+
|
|
46
69
|
def validate_security_txt(content)
|
|
47
70
|
findings = []
|
|
48
71
|
required_fields = %w[Contact]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Hedra
|
|
6
|
+
# URL validation and sanitization
|
|
7
|
+
class UrlValidator
|
|
8
|
+
ALLOWED_SCHEMES = %w[http https].freeze
|
|
9
|
+
MAX_URL_LENGTH = 2048
|
|
10
|
+
DANGEROUS_CHARS = ['<', '>', '"', '{', '}', '|', '\\', '^', '`', '[', ']'].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def valid?(url)
|
|
14
|
+
return false if url.nil? || url.empty?
|
|
15
|
+
return false if url.length > MAX_URL_LENGTH
|
|
16
|
+
|
|
17
|
+
uri = parse_url(url)
|
|
18
|
+
return false unless uri
|
|
19
|
+
return false unless ALLOWED_SCHEMES.include?(uri.scheme)
|
|
20
|
+
return false unless valid_host?(uri.host)
|
|
21
|
+
return false if contains_dangerous_chars?(url)
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
rescue StandardError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate!(url)
|
|
29
|
+
raise Error, 'URL cannot be empty' if url.nil? || url.empty?
|
|
30
|
+
raise Error, "URL too long (max #{MAX_URL_LENGTH} characters)" if url.length > MAX_URL_LENGTH
|
|
31
|
+
|
|
32
|
+
uri = parse_url(url)
|
|
33
|
+
raise Error, 'Invalid URL format' unless uri
|
|
34
|
+
raise Error, "Invalid scheme: #{uri.scheme}. Only HTTP/HTTPS allowed" unless ALLOWED_SCHEMES.include?(uri.scheme)
|
|
35
|
+
raise Error, 'Invalid or missing host' unless valid_host?(uri.host)
|
|
36
|
+
raise Error, 'URL contains dangerous characters' if contains_dangerous_chars?(url)
|
|
37
|
+
|
|
38
|
+
uri
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sanitize(url)
|
|
42
|
+
url = url.strip
|
|
43
|
+
url = "https://#{url}" unless url.start_with?('http://', 'https://')
|
|
44
|
+
url
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize(url)
|
|
48
|
+
uri = parse_url(url)
|
|
49
|
+
return url unless uri
|
|
50
|
+
|
|
51
|
+
# Remove default ports
|
|
52
|
+
port = if (uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443)
|
|
53
|
+
nil
|
|
54
|
+
else
|
|
55
|
+
uri.port
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Rebuild URL
|
|
59
|
+
normalized = "#{uri.scheme}://#{uri.host}"
|
|
60
|
+
normalized += ":#{port}" if port
|
|
61
|
+
normalized += uri.path if uri.path && uri.path != '/'
|
|
62
|
+
normalized += "?#{uri.query}" if uri.query
|
|
63
|
+
normalized
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_url(url)
|
|
69
|
+
URI.parse(url)
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_host?(host)
|
|
75
|
+
return false if host.nil? || host.empty?
|
|
76
|
+
return false if host.length > 253 # Max domain length
|
|
77
|
+
|
|
78
|
+
# Check for valid hostname format
|
|
79
|
+
return false if host.start_with?('.') || host.end_with?('.')
|
|
80
|
+
return false if host.include?('..')
|
|
81
|
+
|
|
82
|
+
# Check for localhost/private IPs in production
|
|
83
|
+
return false if host == 'localhost'
|
|
84
|
+
return false if host.start_with?('127.', '10.', '192.168.', '172.')
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def contains_dangerous_chars?(url)
|
|
90
|
+
DANGEROUS_CHARS.any? { |char| url.include?(char) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/hedra/version.rb
CHANGED
data/lib/hedra.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hedra
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bl4ckstack
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: concurrent-ruby
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '5.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: openssl
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 3.1.2
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 3.1.2
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: pastel
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -109,6 +123,7 @@ files:
|
|
|
109
123
|
- config/example_rules.yml
|
|
110
124
|
- lib/hedra.rb
|
|
111
125
|
- lib/hedra/analyzer.rb
|
|
126
|
+
- lib/hedra/banner.rb
|
|
112
127
|
- lib/hedra/baseline.rb
|
|
113
128
|
- lib/hedra/cache.rb
|
|
114
129
|
- lib/hedra/certificate_checker.rb
|
|
@@ -123,6 +138,7 @@ files:
|
|
|
123
138
|
- lib/hedra/rate_limiter.rb
|
|
124
139
|
- lib/hedra/scorer.rb
|
|
125
140
|
- lib/hedra/security_txt_checker.rb
|
|
141
|
+
- lib/hedra/url_validator.rb
|
|
126
142
|
- lib/hedra/version.rb
|
|
127
143
|
homepage: https://github.com/bl4ckstack/hedra
|
|
128
144
|
licenses:
|
|
@@ -143,7 +159,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
143
159
|
- !ruby/object:Gem::Version
|
|
144
160
|
version: '0'
|
|
145
161
|
requirements: []
|
|
146
|
-
rubygems_version: 3.
|
|
162
|
+
rubygems_version: 3.7.2
|
|
147
163
|
specification_version: 4
|
|
148
164
|
summary: Security header analyzer CLI
|
|
149
165
|
test_files: []
|