hedra 2.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14f6f09e8b0bf8318f6f190c0f671aaa901f08a575983ed8c1cad890c965e2a7
4
- data.tar.gz: 74054507d7d981da9fedcd4830d66e6a0c7207c2d2c399f958d814c470d843d9
3
+ metadata.gz: 04726510b1778538e154b1eea4d68bbbed1fe306d9e026e73275e6f6cbf586ed
4
+ data.tar.gz: 36f7716980ea5fb86bd431c2b6f48ea591b4b9e793557893623d0a4c1f88a6fd
5
5
  SHA512:
6
- metadata.gz: d4a3fde0ef57eb572aba47c037a874461e140da579f644dad0b5d8ae53e09917433481734a010be02bb80a605b030f11e70b8a1c38ef64d2925f88090d58172d
7
- data.tar.gz: 85a6bc6e254bc2636d38b58bb02931d52aeabe77c1cf18cd2667b99256c9b5fb1c3508e93e595e9ef38f93be628e02e96d1f1c49b54ceeb53e0d73aeead9c39d
6
+ metadata.gz: d7396aca2eb8bc377f7a203c15729aa48864b3394a79b53b3490c4c2e06719696e313b3ebd097fce4f69b9006c5a44903b8ab94a7dd68da257289e584c811539
7
+ data.tar.gz: b50e9f48dda4b48c505ce948a01d3b5b2d5f2a669d459d6398f6c8801ce18bda53edf1da2132b045743e8575225c194fde6e42deb20eb530d5175aa1af6f54a1
@@ -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) ? value.join(', ') : value.to_s
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
- @custom_rules.each do |rule|
257
- header_name = rule['header'].downcase
258
- pattern = Regexp.new(rule['pattern']) if rule['pattern']
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
- if rule['type'] == 'missing' && !headers.key?(header_name)
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
@@ -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
- 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
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
- data['value']
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
- 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)
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
- FileUtils.rm_rf(@cache_dir)
60
- FileUtils.mkdir_p(@cache_dir)
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
- raise CircuitOpenError, 'Circuit breaker is open' if open? && !should_attempt_reset?
23
+ @mutex.synchronize do
24
+ raise CircuitOpenError, 'Circuit breaker is open' if open? && !should_attempt_reset?
23
25
 
24
- if open? && should_attempt_reset?
25
- @state = :half_open
26
- @half_open_attempts = 0
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
- if half_open?
55
- @half_open_attempts += 1
56
- if @half_open_attempts >= HALF_OPEN_ATTEMPTS
57
- @state = :closed
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
- @failure_count += 1
67
- @last_failure_time = Time.now
71
+ @mutex.synchronize do
72
+ @failure_count += 1
73
+ @last_failure_time = Time.now
68
74
 
69
- return unless @failure_count >= @failure_threshold
75
+ return unless @failure_count >= @failure_threshold
70
76
 
71
- @state = :open
77
+ @state = :open
78
+ end
72
79
  end
73
80
 
74
81
  def should_attempt_reset?
75
- @last_failure_time && (Time.now - @last_failure_time) >= @timeout
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
- uri = URI.parse(url)
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.load_file(CONFIG_FILE)
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
@@ -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
- puts "Completed #{@total} items in #{elapsed.round(2)}s"
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
@@ -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
- base_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port && ![80, 443].include?(uri.port)}"
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
- findings.concat(validate_security_txt(response.body.to_s))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hedra
4
- VERSION = '2.0.2'
4
+ VERSION = '2.0.3'
5
5
  end
data/lib/hedra.rb CHANGED
@@ -7,6 +7,8 @@ module Hedra
7
7
  end
8
8
 
9
9
  require_relative 'hedra/version'
10
+ require_relative 'hedra/banner'
11
+ require_relative 'hedra/url_validator'
10
12
  require_relative 'hedra/config'
11
13
  require_relative 'hedra/scorer'
12
14
  require_relative 'hedra/circuit_breaker'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hedra
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - bl4ckstack
@@ -123,6 +123,7 @@ files:
123
123
  - config/example_rules.yml
124
124
  - lib/hedra.rb
125
125
  - lib/hedra/analyzer.rb
126
+ - lib/hedra/banner.rb
126
127
  - lib/hedra/baseline.rb
127
128
  - lib/hedra/cache.rb
128
129
  - lib/hedra/certificate_checker.rb
@@ -137,6 +138,7 @@ files:
137
138
  - lib/hedra/rate_limiter.rb
138
139
  - lib/hedra/scorer.rb
139
140
  - lib/hedra/security_txt_checker.rb
141
+ - lib/hedra/url_validator.rb
140
142
  - lib/hedra/version.rb
141
143
  homepage: https://github.com/bl4ckstack/hedra
142
144
  licenses: