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 +4 -4
- data/README.md +115 -2
- data/lib/hedra/analyzer.rb +54 -15
- 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 +67 -13
- data/lib/hedra/config.rb +11 -2
- data/lib/hedra/cors_checker.rb +251 -0
- data/lib/hedra/ct_checker.rb +286 -0
- data/lib/hedra/dns_checker.rb +274 -0
- data/lib/hedra/plugin_manager.rb +23 -1
- data/lib/hedra/progress_tracker.rb +6 -1
- data/lib/hedra/protocol_checker.rb +228 -0
- 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/sri_checker.rb +151 -0
- data/lib/hedra/url_validator.rb +94 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +7 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b852e94cfa441a1597fb40438491f15939625b1da90734dcb0a05cb3ac06609a
|
|
4
|
+
data.tar.gz: 00bd6a8bccc0be126058ec0b8795f1b193d5e42c642041e2d5415955ccef9e81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/hedra/analyzer.rb
CHANGED
|
@@ -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)
|
|
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
|
-
@
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
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
|
@@ -71,7 +71,8 @@ module Hedra
|
|
|
71
71
|
|
|
72
72
|
urls.each do |url|
|
|
73
73
|
response = client.get(url)
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|