hedra 1.0.0 → 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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +374 -213
- data/config/example_config.yml +88 -10
- data/lib/hedra/analyzer.rb +10 -2
- data/lib/hedra/baseline.rb +83 -0
- data/lib/hedra/cache.rb +67 -0
- data/lib/hedra/certificate_checker.rb +94 -0
- data/lib/hedra/circuit_breaker.rb +80 -0
- data/lib/hedra/cli.rb +232 -16
- data/lib/hedra/config.rb +1 -1
- data/lib/hedra/exporter.rb +7 -0
- data/lib/hedra/html_reporter.rb +136 -0
- data/lib/hedra/http_client.rb +46 -9
- data/lib/hedra/progress_tracker.rb +45 -0
- data/lib/hedra/rate_limiter.rb +60 -0
- data/lib/hedra/security_txt_checker.rb +93 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +17 -9
- metadata +13 -5
data/config/example_config.yml
CHANGED
|
@@ -1,17 +1,95 @@
|
|
|
1
|
-
# Hedra Configuration
|
|
2
|
-
#
|
|
1
|
+
# Hedra Configuration File
|
|
2
|
+
# Place this file at ~/.hedra/config.yml
|
|
3
3
|
|
|
4
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
14
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
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
|
data/lib/hedra/analyzer.rb
CHANGED
|
@@ -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
|
data/lib/hedra/cache.rb
ADDED
|
@@ -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
|