hedra 2.0.2 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14f6f09e8b0bf8318f6f190c0f671aaa901f08a575983ed8c1cad890c965e2a7
4
- data.tar.gz: 74054507d7d981da9fedcd4830d66e6a0c7207c2d2c399f958d814c470d843d9
3
+ metadata.gz: b852e94cfa441a1597fb40438491f15939625b1da90734dcb0a05cb3ac06609a
4
+ data.tar.gz: 00bd6a8bccc0be126058ec0b8795f1b193d5e42c642041e2d5415955ccef9e81
5
5
  SHA512:
6
- metadata.gz: d4a3fde0ef57eb572aba47c037a874461e140da579f644dad0b5d8ae53e09917433481734a010be02bb80a605b030f11e70b8a1c38ef64d2925f88090d58172d
7
- data.tar.gz: 85a6bc6e254bc2636d38b58bb02931d52aeabe77c1cf18cd2667b99256c9b5fb1c3508e93e595e9ef38f93be628e02e96d1f1c49b54ceeb53e0d73aeead9c39d
6
+ metadata.gz: 908b5f2e81cefb60ee58af16e89e50f092fc7338af99bd89d1b47168a8b0ef1d65044344666f46581246405c291d69fe61a1b2e930234829d48b8003df3bfa65
7
+ data.tar.gz: 309c0cf041eb18ab775696d8b4ca9a8771cf8c57c75709ad86f422c42ee42aebb731f620d2f573ec8edc1f338a4a7d66ee2f8078939f3c75ed33c825c847c550
data/README.md CHANGED
@@ -150,6 +150,30 @@ hedra plugin remove plugin_name
150
150
  - Signature algorithm strength
151
151
  - Key size validation
152
152
  - Chain verification
153
+ - TLS version detection (TLS 1.2/1.3)
154
+ - Certificate Transparency log verification
155
+
156
+ **Protocol Security:**
157
+ - HTTP/2 and HTTP/3 detection
158
+ - TLS version enforcement
159
+ - Insecure protocol warnings
160
+
161
+ **CORS Security:**
162
+ - Access-Control-Allow-Origin validation
163
+ - Wildcard and null origin detection
164
+ - Credentials with wildcard prevention
165
+ - Dangerous HTTP methods detection
166
+ - Sensitive header exposure checks
167
+
168
+ **Subresource Integrity (SRI):**
169
+ - External script/stylesheet SRI validation
170
+ - Crossorigin attribute verification
171
+ - Same-origin resource detection
172
+
173
+ **DNS Security:**
174
+ - DNSSEC validation
175
+ - CAA (Certificate Authority Authorization) records
176
+ - DNS-based security policy enforcement
153
177
 
154
178
  **RFC 9116:**
155
179
  - security.txt file presence and format
@@ -375,6 +399,80 @@ hedra scan -f urls.txt --output report.html --format html
375
399
 
376
400
  Interactive report with sorting, filtering, and charts.
377
401
 
402
+ ## Advanced Security Checks
403
+
404
+ ### Subresource Integrity (SRI)
405
+ Validates that external scripts and stylesheets use SRI attributes to prevent tampering:
406
+ ```bash
407
+ hedra scan https://myapp.com --check-sri
408
+ ```
409
+
410
+ **Checks:**
411
+ - External resources without integrity attributes
412
+ - Missing crossorigin attributes
413
+ - Same-origin vs cross-origin detection
414
+
415
+ ### CORS Policy Analysis
416
+ Validates Cross-Origin Resource Sharing configuration for security issues:
417
+ ```bash
418
+ hedra scan https://api.myapp.com --check-cors
419
+ ```
420
+
421
+ **Detects:**
422
+ - Wildcard origins with credentials (critical vulnerability)
423
+ - Null origin allowance
424
+ - Insecure HTTP origins
425
+ - Dangerous HTTP methods (TRACE, TRACK)
426
+ - Overly permissive configurations
427
+ - Sensitive header exposure
428
+
429
+ ### Protocol Version Detection
430
+ Checks HTTP and TLS protocol versions:
431
+ ```bash
432
+ hedra scan https://myapp.com --check-protocol
433
+ ```
434
+
435
+ **Validates:**
436
+ - HTTP/1.1 vs HTTP/2 vs HTTP/3
437
+ - TLS 1.2/1.3 enforcement
438
+ - Deprecated protocol detection (SSLv3, TLS 1.0/1.1)
439
+ - Protocol upgrade recommendations
440
+
441
+ ### Certificate Transparency
442
+ Verifies certificates are logged in CT logs:
443
+ ```bash
444
+ hedra audit https://myapp.com --check-ct
445
+ ```
446
+
447
+ **Checks:**
448
+ - SCT (Signed Certificate Timestamp) presence
449
+ - Multiple independent CT logs
450
+ - SCT delivery methods (extension, OCSP, TLS)
451
+
452
+ ### DNS Security
453
+ Validates DNS-level security features:
454
+ ```bash
455
+ hedra audit https://myapp.com --check-dns
456
+ ```
457
+
458
+ **Validates:**
459
+ - DNSSEC enablement
460
+ - CAA records for certificate issuance control
461
+ - CAA tags (issue, issuewild, iodef)
462
+ - DNS-based security policies
463
+
464
+ ### Combined Advanced Scan
465
+ Run all advanced checks together:
466
+ ```bash
467
+ hedra audit https://myapp.com \
468
+ --check-sri \
469
+ --check-cors \
470
+ --check-protocol \
471
+ --check-ct \
472
+ --check-dns \
473
+ --output comprehensive-report.json
474
+ ```
475
+
378
476
  ## Real-World Examples
379
477
 
380
478
  ### Basic Security Audit
@@ -382,6 +480,23 @@ Interactive report with sorting, filtering, and charts.
382
480
  hedra scan https://myapp.com
383
481
  ```
384
482
 
483
+ ### Comprehensive Security Audit with All Checks
484
+ ```bash
485
+ hedra audit https://myapp.com \
486
+ --check-sri \
487
+ --check-ct \
488
+ --check-dns \
489
+ --json \
490
+ --output full-audit.json
491
+ ```
492
+
493
+ ### Quick CORS and Protocol Check
494
+ ```bash
495
+ hedra scan https://api.myapp.com \
496
+ --check-cors \
497
+ --check-protocol
498
+ ```
499
+
385
500
  ### Production Deployment Check
386
501
  ```bash
387
502
  # Save baseline after deployment
@@ -503,5 +618,3 @@ hedra scan https://slow-server.com --timeout 60
503
618
  MIT License - see [LICENSE](LICENSE) for details.
504
619
 
505
620
  ---
506
-
507
- **Built by [BlackStack](https://github.com/bl4ckstack)** • Securing the web, one header at a time.
@@ -61,15 +61,22 @@ module Hedra
61
61
  }
62
62
  }.freeze
63
63
 
64
- def initialize(check_certificates: true, check_security_txt: false)
64
+ def initialize(check_certificates: true, check_security_txt: false, check_sri: false, check_cors: true, check_protocol: true, check_ct: false, check_dns: false)
65
65
  @plugin_manager = PluginManager.new
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
+ @sri_checker = check_sri ? SriChecker.new : nil
70
+ @cors_checker = check_cors ? CorsChecker.new : nil
71
+ @protocol_checker = check_protocol ? ProtocolChecker.new : nil
72
+ @ct_checker = check_ct ? CtChecker.new : nil
73
+ @dns_checker = check_dns ? DnsChecker.new : nil
74
+ @custom_rules = []
75
+ @mutex = Mutex.new # Thread safety for custom rules
69
76
  load_custom_rules
70
77
  end
71
78
 
72
- def analyze(url, headers, http_client: nil)
79
+ def analyze(url, headers, http_client: nil, response: nil, html_content: nil)
73
80
  normalized_headers = normalize_headers(headers)
74
81
  findings = []
75
82
 
@@ -102,6 +109,24 @@ module Hedra
102
109
  # Check security.txt
103
110
  findings.concat(@security_txt_checker.check(url, http_client)) if @security_txt_checker && http_client
104
111
 
112
+ # Check Subresource Integrity (SRI)
113
+ if @sri_checker
114
+ @sri_checker.instance_variable_set(:@http_client, http_client) if http_client
115
+ findings.concat(@sri_checker.check(url, html_content))
116
+ end
117
+
118
+ # Check CORS configuration
119
+ findings.concat(@cors_checker.check(normalized_headers, url)) if @cors_checker
120
+
121
+ # Check HTTP/TLS protocol versions
122
+ findings.concat(@protocol_checker.check(url, response)) if @protocol_checker
123
+
124
+ # Check Certificate Transparency
125
+ findings.concat(@ct_checker.check(url)) if @ct_checker
126
+
127
+ # Check DNS security (DNSSEC, CAA)
128
+ findings.concat(@dns_checker.check(url)) if @dns_checker
129
+
105
130
  # Calculate security score
106
131
  score = @scorer.calculate(normalized_headers, findings)
107
132
 
@@ -119,9 +144,15 @@ module Hedra
119
144
  def normalize_headers(headers)
120
145
  normalized = {}
121
146
  headers.each do |key, value|
147
+ next if value.nil? # Skip nil values
148
+
122
149
  normalized_key = key.to_s.downcase
123
150
  # Handle array values (multiple header values)
124
- normalized_value = value.is_a?(Array) ? value.join(', ') : value.to_s
151
+ normalized_value = if value.is_a?(Array)
152
+ value.compact.join(', ')
153
+ else
154
+ value.to_s
155
+ end
125
156
  normalized[normalized_key] = normalized_value
126
157
  end
127
158
  normalized
@@ -253,30 +284,38 @@ module Hedra
253
284
  def apply_custom_rules(headers)
254
285
  findings = []
255
286
 
256
- @custom_rules.each do |rule|
257
- header_name = rule['header'].downcase
258
- pattern = Regexp.new(rule['pattern']) if rule['pattern']
287
+ @mutex.synchronize do
288
+ @custom_rules.each do |rule|
289
+ header_name = rule['header'].downcase
290
+ pattern = rule['pattern'] ? Regexp.new(rule['pattern']) : nil
259
291
 
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
292
+ if rule['type'] == 'missing' && !headers.key?(header_name)
269
293
  findings << {
270
294
  header: header_name,
271
295
  issue: rule['message'],
272
296
  severity: rule['severity'].to_sym,
273
297
  recommended_fix: rule['fix']
274
298
  }
299
+ elsif rule['type'] == 'pattern' && headers[header_name]
300
+ if pattern && headers[header_name] =~ pattern
301
+ findings << {
302
+ header: header_name,
303
+ issue: rule['message'],
304
+ severity: rule['severity'].to_sym,
305
+ recommended_fix: rule['fix']
306
+ }
307
+ end
275
308
  end
276
309
  end
277
310
  end
278
311
 
279
312
  findings
280
313
  end
314
+
315
+ def reload_custom_rules
316
+ @mutex.synchronize do
317
+ load_custom_rules
318
+ end
319
+ end
281
320
  end
282
321
  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
@@ -71,7 +71,8 @@ module Hedra
71
71
 
72
72
  urls.each do |url|
73
73
  response = client.get(url)
74
- result = analyzer.analyze(url, response.headers.to_h)
74
+ headers = safe_headers_to_hash(response.headers)
75
+ result = analyzer.analyze(url, headers)
75
76
  current_results << result
76
77
  rescue StandardError => e
77
78
  warn "Failed to scan #{url}: #{e.message}"
@@ -155,6 +156,14 @@ module Hedra
155
156
  true
156
157
  end
157
158
 
159
+ # Override to show banner when no command is given
160
+ def self.start(given_args = ARGV, config = {})
161
+ if given_args.empty? || (given_args.length == 1 && %w[-h --help help].include?(given_args.first))
162
+ Banner.show unless given_args.include?('help')
163
+ end
164
+ super
165
+ end
166
+
158
167
  desc 'scan URL_OR_FILE', 'Scan one or multiple URLs for security headers'
159
168
  option :file, type: :boolean, aliases: '-f', desc: 'Treat argument as file with URLs'
160
169
  option :concurrency, type: :numeric, aliases: '-c', default: 10, desc: 'Concurrent requests'
@@ -169,6 +178,11 @@ module Hedra
169
178
  option :cache_ttl, type: :numeric, default: 3600, desc: 'Cache TTL in seconds'
170
179
  option :check_certificates, type: :boolean, default: true, desc: 'Check SSL certificates'
171
180
  option :check_security_txt, type: :boolean, default: false, desc: 'Check for security.txt'
181
+ option :check_sri, type: :boolean, default: false, desc: 'Check Subresource Integrity'
182
+ option :check_cors, type: :boolean, default: true, desc: 'Check CORS configuration'
183
+ option :check_protocol, type: :boolean, default: true, desc: 'Check HTTP/TLS protocol versions'
184
+ option :check_ct, type: :boolean, default: false, desc: 'Check Certificate Transparency'
185
+ option :check_dns, type: :boolean, default: false, desc: 'Check DNSSEC and CAA records'
172
186
  option :save_baseline, type: :string, desc: 'Save results as baseline'
173
187
  option :progress, type: :boolean, default: true, desc: 'Show progress bar'
174
188
  def scan(target) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -178,7 +192,12 @@ module Hedra
178
192
  client = build_http_client
179
193
  analyzer = Analyzer.new(
180
194
  check_certificates: options[:check_certificates],
181
- check_security_txt: options[:check_security_txt]
195
+ check_security_txt: options[:check_security_txt],
196
+ check_sri: options[:check_sri],
197
+ check_cors: options[:check_cors],
198
+ check_protocol: options[:check_protocol],
199
+ check_ct: options[:check_ct],
200
+ check_dns: options[:check_dns]
182
201
  )
183
202
  cache = options[:cache] ? Cache.new(ttl: options[:cache_ttl]) : nil
184
203
  rate_limiter = options[:rate] ? RateLimiter.new(options[:rate]) : nil
@@ -203,7 +222,9 @@ module Hedra
203
222
  log_info("Cache hit: #{url}") if @verbose
204
223
  else
205
224
  response = client.get(url)
206
- result = analyzer.analyze(url, response.headers.to_h, http_client: client)
225
+ headers = safe_headers_to_hash(response.headers)
226
+ html_content = response.body.to_s if options[:check_sri]
227
+ result = analyzer.analyze(url, headers, http_client: client, response: response, html_content: html_content)
207
228
  cache&.set(url, result)
208
229
  end
209
230
 
@@ -238,17 +259,29 @@ module Hedra
238
259
  option :timeout, type: :numeric, aliases: '-t', default: 10, desc: 'Request timeout'
239
260
  option :check_certificates, type: :boolean, default: true, desc: 'Check SSL certificates'
240
261
  option :check_security_txt, type: :boolean, default: true, desc: 'Check for security.txt'
262
+ option :check_sri, type: :boolean, default: true, desc: 'Check Subresource Integrity'
263
+ option :check_cors, type: :boolean, default: true, desc: 'Check CORS configuration'
264
+ option :check_protocol, type: :boolean, default: true, desc: 'Check HTTP/TLS protocol versions'
265
+ option :check_ct, type: :boolean, default: true, desc: 'Check Certificate Transparency'
266
+ option :check_dns, type: :boolean, default: true, desc: 'Check DNSSEC and CAA records'
241
267
  def audit(url)
242
268
  setup_logging
243
269
  client = build_http_client
244
270
  analyzer = Analyzer.new(
245
271
  check_certificates: options[:check_certificates],
246
- check_security_txt: options[:check_security_txt]
272
+ check_security_txt: options[:check_security_txt],
273
+ check_sri: options[:check_sri],
274
+ check_cors: options[:check_cors],
275
+ check_protocol: options[:check_protocol],
276
+ check_ct: options[:check_ct],
277
+ check_dns: options[:check_dns]
247
278
  )
248
279
 
249
280
  begin
250
281
  response = client.get(url)
251
- result = analyzer.analyze(url, response.headers.to_h, http_client: client)
282
+ headers = safe_headers_to_hash(response.headers)
283
+ html_content = response.body.to_s if options[:check_sri]
284
+ result = analyzer.analyze(url, headers, http_client: client, response: response, html_content: html_content)
252
285
 
253
286
  if options[:json]
254
287
  output = JSON.pretty_generate(result)
@@ -281,7 +314,8 @@ module Hedra
281
314
  loop do
282
315
  begin
283
316
  response = client.get(url)
284
- result = analyzer.analyze(url, response.headers.to_h)
317
+ headers = safe_headers_to_hash(response.headers)
318
+ result = analyzer.analyze(url, headers)
285
319
  print_result(result)
286
320
  rescue StandardError => e
287
321
  log_error("Watch check failed: #{e.message}")
@@ -303,8 +337,10 @@ module Hedra
303
337
  response1 = client.get(url1)
304
338
  response2 = client.get(url2)
305
339
 
306
- result1 = analyzer.analyze(url1, response1.headers.to_h)
307
- result2 = analyzer.analyze(url2, response2.headers.to_h)
340
+ headers1 = safe_headers_to_hash(response1.headers)
341
+ headers2 = safe_headers_to_hash(response2.headers)
342
+ result1 = analyzer.analyze(url1, headers1)
343
+ result2 = analyzer.analyze(url2, headers2)
308
344
 
309
345
  print_comparison(result1, result2)
310
346
  rescue StandardError => e
@@ -328,6 +364,11 @@ module Hedra
328
364
  say "Exported to #{options[:output]}", :green unless options[:quiet]
329
365
  end
330
366
 
367
+ desc 'version', 'Show version information'
368
+ def version
369
+ Banner.show
370
+ end
371
+
331
372
  desc 'plugin SUBCOMMAND', 'Manage plugins'
332
373
  subcommand 'plugin', Hedra::PluginCLI
333
374
 
@@ -354,7 +395,8 @@ module Hedra
354
395
 
355
396
  urls.each do |url|
356
397
  response = client.get(url)
357
- result = analyzer.analyze(url, response.headers.to_h, http_client: client)
398
+ headers = safe_headers_to_hash(response.headers)
399
+ result = analyzer.analyze(url, headers, http_client: client)
358
400
  results << result
359
401
 
360
402
  if result[:score] < options[:threshold]
@@ -432,10 +474,7 @@ module Hedra
432
474
  end
433
475
 
434
476
  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
477
+ UrlValidator.valid?(url)
439
478
  end
440
479
 
441
480
  def with_concurrency(items, concurrency)
@@ -548,6 +587,21 @@ module Hedra
548
587
  puts Pastel.new.cyan("INFO: #{message}")
549
588
  end
550
589
 
590
+ def safe_headers_to_hash(headers)
591
+ return {} if headers.nil?
592
+
593
+ # Handle different header object types
594
+ hash = headers.respond_to?(:to_h) ? headers.to_h : {}
595
+
596
+ # Clean up nil values
597
+ hash.compact.transform_values do |value|
598
+ value.nil? ? '' : value
599
+ end
600
+ rescue StandardError => e
601
+ log_error("Failed to convert headers: #{e.message}") if @debug
602
+ {}
603
+ end
604
+
551
605
  def severity_badge(severity)
552
606
  pastel = Pastel.new
553
607
  case severity.to_s