hedra 1.0.1 → 2.0.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.
@@ -1,17 +1,95 @@
1
- # Hedra Configuration Example
2
- # Copy to ~/.hedra/config.yml to customize
1
+ # Hedra Configuration File
2
+ # Place this file at ~/.hedra/config.yml
3
3
 
4
- # HTTP client settings
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
- # Proxy settings (optional)
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
- # Output preferences
14
- output_format: table # table, json, or csv
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
- # Rate limiting (optional)
17
- # rate_limit: "5/s"
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
@@ -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
 
@@ -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
@@ -0,0 +1,67 @@
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
+
12
+ def initialize(cache_dir: nil, ttl: DEFAULT_TTL)
13
+ @cache_dir = cache_dir || File.join(Config::CONFIG_DIR, 'cache')
14
+ @ttl = ttl
15
+ FileUtils.mkdir_p(@cache_dir)
16
+ end
17
+
18
+ def get(key)
19
+ cache_file = cache_path(key)
20
+ return nil unless File.exist?(cache_file)
21
+
22
+ data = JSON.parse(File.read(cache_file))
23
+ return nil if expired?(data['timestamp'])
24
+
25
+ data['value']
26
+ rescue StandardError => e
27
+ warn "Cache read error: #{e.message}"
28
+ nil
29
+ end
30
+
31
+ def set(key, value)
32
+ cache_file = cache_path(key)
33
+ data = {
34
+ 'timestamp' => Time.now.to_i,
35
+ 'value' => value
36
+ }
37
+ File.write(cache_file, JSON.generate(data))
38
+ rescue StandardError => e
39
+ warn "Cache write error: #{e.message}"
40
+ end
41
+
42
+ def clear
43
+ FileUtils.rm_rf(@cache_dir)
44
+ FileUtils.mkdir_p(@cache_dir)
45
+ end
46
+
47
+ def clear_expired
48
+ Dir.glob(File.join(@cache_dir, '*')).each do |file|
49
+ data = JSON.parse(File.read(file))
50
+ File.delete(file) if expired?(data['timestamp'])
51
+ rescue StandardError
52
+ # Skip invalid cache files
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def cache_path(key)
59
+ hash = Digest::SHA256.hexdigest(key)
60
+ File.join(@cache_dir, hash)
61
+ end
62
+
63
+ def expired?(timestamp)
64
+ (Time.now.to_i - timestamp) > @ttl
65
+ end
66
+ end
67
+ 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