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.
@@ -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
 
@@ -109,7 +117,14 @@ module Hedra
109
117
  private
110
118
 
111
119
  def normalize_headers(headers)
112
- headers.transform_keys { |k| k.to_s.downcase }
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
- rules = YAML.load_file(config_path)
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
@@ -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